merge: wallet feat/auth-portfolio slice

# Conflicts:
#	apps/web/package.json
#	apps/web/src/screens/portfolio/index.tsx
This commit is contained in:
Hanzo AI
2026-04-30 14:02:56 -07:00
19 changed files with 1423 additions and 17 deletions
+7
View File
@@ -14,7 +14,14 @@
"@hanzo/gui": "^7.0.0",
"@luxfi/wallet-analytics": "workspace:^",
"@luxfi/wallet-brand": "workspace:^",
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.7.1",
"@scure/bip32": "^1.5.0",
"@scure/bip39": "^1.4.0",
"@tanstack/react-query": "^5.90.0",
"@walletconnect/sign-client": "^2.21.0",
"@walletconnect/utils": "^2.21.0",
"qrcode.react": "^4.0.0",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-router-dom": "^7.0.0",
+102
View File
@@ -0,0 +1,102 @@
/**
* Symmetric crypto primitives for the wallet. Standard algorithms only.
*
* - PIN verifier: scrypt(PIN, salt_v) — domain "verify"
* - Encryption key: scrypt(PIN, salt_k) — domain "encrypt"
* - Cipher: AES-256-GCM via Web Crypto
*
* The two scrypt outputs use INDEPENDENT salts. A compromise of the
* persisted verifier hash does not yield the encryption key, even if
* the same PIN was used.
*
* Parameters: scrypt N=2^17 r=8 p=1 — interactive-login bracket per RFC 7914.
* AES-256-GCM with a fresh 12-byte nonce per encryption.
*/
import { scrypt as scryptAsync } from "@noble/hashes/scrypt"
import { bytesToHex, hexToBytes } from "@noble/hashes/utils"
const SCRYPT_PARAMS = { N: 1 << 17, r: 8, p: 1, dkLen: 32 } as const
const PIN_DOMAIN = new TextEncoder().encode("lux-wallet/pin-verify/v1")
const KEY_DOMAIN = new TextEncoder().encode("lux-wallet/aes-key/v1")
export interface EncryptedEnvelope {
/** Hex-encoded AES-GCM ciphertext (includes the 16-byte auth tag). */
ciphertext: string
/** Hex-encoded 12-byte AES-GCM nonce. */
nonce: string
/** Hex-encoded 16-byte salt for the encryption-key KDF. */
salt: string
}
export interface PinHash {
hash: string
salt: string
}
function randomBytes(n: number): Uint8Array {
const out = new Uint8Array(n)
crypto.getRandomValues(out)
return out
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length)
out.set(a, 0)
out.set(b, a.length)
return out
}
async function deriveKey(pin: string, salt: Uint8Array, domain: Uint8Array): Promise<Uint8Array> {
const password = concat(domain, new TextEncoder().encode(pin))
return scryptAsync(password, salt, SCRYPT_PARAMS)
}
export async function hashPIN(pin: string): Promise<PinHash> {
const salt = randomBytes(16)
const hash = await deriveKey(pin, salt, PIN_DOMAIN)
return { hash: bytesToHex(hash), salt: bytesToHex(salt) }
}
export async function verifyPIN(pin: string, expected: PinHash): Promise<boolean> {
const salt = hexToBytes(expected.salt)
const got = await deriveKey(pin, salt, PIN_DOMAIN)
const want = hexToBytes(expected.hash)
if (got.length !== want.length) return false
// Constant-time comparison — defends against timing side-channel.
let diff = 0
for (let i = 0; i < got.length; i++) diff |= got[i]! ^ want[i]!
return diff === 0
}
async function importAesKey(raw: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"])
}
export async function encryptMnemonic(mnemonic: string, pin: string): Promise<EncryptedEnvelope> {
const salt = randomBytes(16)
const keyBytes = await deriveKey(pin, salt, KEY_DOMAIN)
const key = await importAesKey(keyBytes)
const nonce = randomBytes(12)
const plaintext = new TextEncoder().encode(mnemonic)
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, plaintext)
return {
ciphertext: bytesToHex(new Uint8Array(ct)),
nonce: bytesToHex(nonce),
salt: bytesToHex(salt),
}
}
export async function decryptMnemonic(env: EncryptedEnvelope, pin: string): Promise<string | null> {
try {
const salt = hexToBytes(env.salt)
const keyBytes = await deriveKey(pin, salt, KEY_DOMAIN)
const key = await importAesKey(keyBytes)
const nonce = hexToBytes(env.nonce)
const ct = hexToBytes(env.ciphertext)
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, key, ct)
return new TextDecoder().decode(pt)
} catch {
// Auth tag mismatch (wrong PIN) or any subtle-crypto failure → reject.
return null
}
}
@@ -0,0 +1,67 @@
/**
* Quiz the user on 3 random word positions before allowing PIN setup.
* Positions chosen ONCE per mount (deterministic during the quiz). Wrong
* answers do not advance; correct answers unlock the Continue button.
*/
import { useMemo, useState } from "react"
import { useNavigate, Navigate } from "react-router-dom"
import { Button, Card, Input, Stack, Text, XStack, YStack } from "@hanzo/gui/web"
import { useMnemonicDraft } from "./mnemonicDraft"
function pickIndices(total: number, count: number): number[] {
const all = Array.from({ length: total }, (_, i) => i)
// FisherYates partial shuffle, take first `count`.
for (let i = 0; i < count; i++) {
const j = i + Math.floor(Math.random() * (all.length - i))
;[all[i], all[j]] = [all[j]!, all[i]!]
}
return all.slice(0, count).sort((a, b) => a - b)
}
export default function ConfirmMnemonic() {
const navigate = useNavigate()
const draft = useMnemonicDraft((s) => s.mnemonic)
// Hooks must run unconditionally; bail after via Navigate.
const words = useMemo(() => (draft ? draft.split(" ") : []), [draft])
const indices = useMemo(() => (words.length === 12 ? pickIndices(12, 3) : []), [words.length])
const [answers, setAnswers] = useState<Record<number, string>>({})
if (!draft) return <Navigate to="/auth/create" replace />
const allCorrect = indices.every((i) => (answers[i] ?? "").trim().toLowerCase() === words[i])
return (
<YStack flex={1} p="$5" gap="$5" maxWidth={520} mx="auto">
<Text fontSize="$8" fontWeight="700">
Confirm recovery phrase
</Text>
<Text col="$neutral2">
Enter the requested words from the phrase you just backed up.
</Text>
<Card p="$4">
<Stack gap="$3">
{indices.map((i) => (
<XStack key={i} ai="center" gap="$3">
<Text width={48} col="$neutral2">
Word {i + 1}
</Text>
<Input
flex={1}
autoCapitalize="none"
autoCorrect={false}
value={answers[i] ?? ""}
onChangeText={(v: string) => setAnswers({ ...answers, [i]: v })}
placeholder="enter word"
/>
</XStack>
))}
</Stack>
</Card>
<Button disabled={!allCorrect} onPress={() => navigate("/auth/pin")} theme="active">
Continue
</Button>
</YStack>
)
}
@@ -0,0 +1,76 @@
/**
* Create-wallet step 1: generate a fresh BIP-39 mnemonic and display it
* for backup. Word display is grid-of-12 with index labels. The user must
* acknowledge they have written it down before proceeding to confirmation.
*
* Mnemonic source: viem's generateMnemonic (BIP-39). Held in component
* state only; persisted only after PIN setup completes via auth.setCredentials.
*/
import { useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { generateMnemonic, english } from "viem/accounts"
import { Button, Card, Stack, Text, XStack, YStack } from "@hanzo/gui/web"
import { useMnemonicDraft } from "./mnemonicDraft"
export default function CreateMnemonic() {
const navigate = useNavigate()
const setDraft = useMnemonicDraft((s) => s.set)
// Generated once per mount. Re-mount to regenerate (Welcome → Create).
const mnemonic = useMemo(() => generateMnemonic(english), [])
const words = mnemonic.split(" ")
const [acknowledged, setAcknowledged] = useState(false)
const onContinue = () => {
setDraft(mnemonic)
navigate("/auth/confirm")
}
return (
<YStack flex={1} p="$5" gap="$5" maxWidth={520} mx="auto">
<Text fontSize="$8" fontWeight="700">
Backup your recovery phrase
</Text>
<Text col="$neutral2">
Write down these 12 words in order and keep them somewhere safe. Anyone
with this phrase controls your wallet. Lux cannot recover it for you.
</Text>
<Card p="$4">
<Stack flexWrap="wrap" flexDirection="row" gap="$2">
{words.map((word, i) => (
<XStack
key={i}
ai="center"
gap="$2"
p="$2"
br="$3"
bw={1}
boc="$neutral3"
flexBasis="32%"
>
<Text col="$neutral3" fontSize="$2" width={20}>
{i + 1}.
</Text>
<Text fontSize="$4" fontWeight="600">
{word}
</Text>
</XStack>
))}
</Stack>
</Card>
<XStack ai="center" gap="$3">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
/>
<Text>I have written down my recovery phrase.</Text>
</XStack>
<Button disabled={!acknowledged} onPress={onContinue} theme="active">
Continue
</Button>
</YStack>
)
}
@@ -0,0 +1,65 @@
/**
* Import-existing-wallet path. User pastes a 12 or 24-word BIP-39 mnemonic;
* we validate against the BIP-39 wordlist + checksum via viem's
* `validateMnemonic`. On success, store the draft and proceed to PIN setup.
*/
import { useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { english, validateMnemonic } from "viem/accounts"
import { Button, Card, Input, Stack, Text, YStack } from "@hanzo/gui/web"
import { useMnemonicDraft } from "./mnemonicDraft"
export default function ImportMnemonic() {
const navigate = useNavigate()
const setDraft = useMnemonicDraft((s) => s.set)
const [phrase, setPhrase] = useState("")
const normalized = useMemo(() => phrase.trim().replace(/\s+/g, " ").toLowerCase(), [phrase])
const wordCount = normalized ? normalized.split(" ").length : 0
const validShape = wordCount === 12 || wordCount === 24
const validBip39 = validShape && validateMnemonic(normalized, english)
const onContinue = () => {
if (!validBip39) return
setDraft(normalized)
// Skip the confirmation quiz on import — user already has the phrase.
navigate("/auth/pin")
}
return (
<YStack flex={1} p="$5" gap="$5" maxWidth={520} mx="auto">
<Text fontSize="$8" fontWeight="700">
Import wallet
</Text>
<Text col="$neutral2">Paste your 12 or 24-word BIP-39 recovery phrase.</Text>
<Card p="$4">
<Stack gap="$3">
<Input
multiline
numberOfLines={4}
autoCapitalize="none"
autoCorrect={false}
placeholder="word word word ..."
value={phrase}
onChangeText={setPhrase}
/>
{phrase && !validShape ? (
<Text col="$red10" fontSize="$2">
Phrase must be exactly 12 or 24 words ({wordCount} entered).
</Text>
) : null}
{validShape && !validBip39 ? (
<Text col="$red10" fontSize="$2">
Phrase failed BIP-39 checksum. Check for typos.
</Text>
) : null}
</Stack>
</Card>
<Button disabled={!validBip39} onPress={onContinue} theme="active">
Continue
</Button>
</YStack>
)
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Set a 6-digit PIN, hashed with scrypt. Two-step: enter, then confirm.
* Storing fails closed if either entry is not exactly 6 digits.
*
* On success, encrypts the draft mnemonic, stores credentials, clears the
* draft, and routes to /portfolio.
*/
import { useState } from "react"
import { useNavigate, Navigate } from "react-router-dom"
import { Button, Card, Input, Stack, Text, YStack } from "@hanzo/gui/web"
import { useAuth } from "../../store/auth"
import { useMnemonicDraft } from "./mnemonicDraft"
const PIN_RE = /^\d{6}$/
export default function SetPIN() {
const navigate = useNavigate()
const draft = useMnemonicDraft((s) => s.mnemonic)
const clearDraft = useMnemonicDraft((s) => s.clear)
const setCredentials = useAuth((s) => s.setCredentials)
const [pin, setPin] = useState("")
const [confirm, setConfirm] = useState("")
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!draft) return <Navigate to="/auth" replace />
const valid = PIN_RE.test(pin) && pin === confirm
const onSubmit = async () => {
if (!valid || busy) return
setBusy(true)
setError(null)
try {
await setCredentials(draft, pin)
clearDraft()
navigate("/portfolio", { replace: true })
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save credentials")
setBusy(false)
}
}
return (
<YStack flex={1} p="$5" gap="$5" maxWidth={420} mx="auto">
<Text fontSize="$8" fontWeight="700">
Set a PIN
</Text>
<Text col="$neutral2">
6 digits. Used to unlock the wallet on subsequent visits. Pick something
you can remember there is no "forgot PIN" button. If you lose it,
recover with your phrase.
</Text>
<Card p="$4">
<Stack gap="$3">
<Input
inputMode="numeric"
secureTextEntry
maxLength={6}
placeholder="6-digit PIN"
value={pin}
onChangeText={(v: string) => setPin(v.replace(/\D/g, ""))}
/>
<Input
inputMode="numeric"
secureTextEntry
maxLength={6}
placeholder="Confirm PIN"
value={confirm}
onChangeText={(v: string) => setConfirm(v.replace(/\D/g, ""))}
/>
{pin && !PIN_RE.test(pin) ? (
<Text col="$red10" fontSize="$2">
PIN must be exactly 6 digits.
</Text>
) : null}
{confirm && pin !== confirm ? (
<Text col="$red10" fontSize="$2">
PINs do not match.
</Text>
) : null}
{error ? (
<Text col="$red10" fontSize="$2">
{error}
</Text>
) : null}
</Stack>
</Card>
<Button disabled={!valid || busy} onPress={onSubmit} theme="active">
{busy ? "Encrypting..." : "Create wallet"}
</Button>
</YStack>
)
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Unlock screen. PIN is the canonical authenticator. Passkey, when present,
* is a UX accelerator that proves possession of the device — the actual key
* material still derives from the PIN; passkey only releases a cached PIN
* stored under the platform authenticator.
*
* For now (Hour 1) we ship the PIN path only. The passkey hook is wired but
* gracefully no-ops when WebAuthn is unavailable or no credential is registered.
*/
import { useState } from "react"
import { useNavigate, Navigate } from "react-router-dom"
import { Button, Card, Input, Stack, Text, YStack } from "@hanzo/gui/web"
import { useAuth } from "../../store/auth"
const PIN_RE = /^\d{6}$/
async function tryPasskey(): Promise<string | null> {
// Optional UX accelerator. If WebAuthn isn't available or no credential is
// registered, return null and fall through to manual PIN entry.
if (typeof navigator === "undefined" || !navigator.credentials) return null
// Platform-bound passkey discovery. We don't enumerate credentials by ID —
// we let the platform pick the discoverable one for this RP.
try {
const challenge = new Uint8Array(32)
crypto.getRandomValues(challenge)
const cred = (await navigator.credentials.get({
publicKey: {
challenge,
rpId: location.hostname,
userVerification: "required",
timeout: 60_000,
},
mediation: "optional",
})) as PublicKeyCredential | null
if (!cred) return null
// Real passkey-bound PIN release happens server-side or via a local
// platform-keychain handoff. The Foundation slice will own that piece.
// Until then, success-of-prompt is treated as "user proved presence" but
// the PIN must still be entered. Return null to keep PIN entry required.
return null
} catch {
return null
}
}
export default function Unlock() {
const navigate = useNavigate()
const unlock = useAuth((s) => s.unlock)
const hasCreds = useAuth((s) => s.encryptedMnemonic !== null && s.pinHash !== null)
const [pin, setPin] = useState("")
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!hasCreds) return <Navigate to="/auth" replace />
const onSubmit = async () => {
if (!PIN_RE.test(pin) || busy) return
setBusy(true)
setError(null)
const ok = await unlock(pin)
if (!ok) {
setError("Incorrect PIN")
setBusy(false)
setPin("")
return
}
navigate("/portfolio", { replace: true })
}
const onBiometric = async () => {
const cached = await tryPasskey()
if (!cached) return
setPin(cached)
void onSubmit()
}
return (
<YStack flex={1} ai="center" jc="center" p="$5" gap="$5">
<Text fontSize="$8" fontWeight="700">
Unlock wallet
</Text>
<Card p="$5" maxWidth={360} width="100%">
<Stack gap="$3">
<Input
inputMode="numeric"
secureTextEntry
maxLength={6}
placeholder="6-digit PIN"
value={pin}
onChangeText={(v: string) => setPin(v.replace(/\D/g, ""))}
onSubmitEditing={onSubmit}
/>
{error ? (
<Text col="$red10" fontSize="$2">
{error}
</Text>
) : null}
<Button disabled={!PIN_RE.test(pin) || busy} onPress={onSubmit} theme="active">
{busy ? "Unlocking..." : "Unlock"}
</Button>
<Button variant="outlined" onPress={onBiometric}>
Use device biometrics
</Button>
</Stack>
</Card>
</YStack>
)
}
+35
View File
@@ -0,0 +1,35 @@
/**
* First-launch splash. Two paths: create new wallet, or import existing.
* Foundation router mounts this at /auth.
*/
import { Link } from "react-router-dom"
import { Button, Card, Stack, Text, YStack } from "@hanzo/gui/web"
import { brand } from "@luxfi/wallet-brand"
export default function Welcome() {
return (
<YStack flex={1} ai="center" jc="center" gap="$6" p="$6">
<Text fontSize="$10" fontWeight="700">
{brand.walletName || "▼"}
</Text>
<Text fontSize="$4" col="$neutral2" ta="center" maxWidth={360}>
{brand.description || "Self-custodial wallet for the Lux ecosystem."}
</Text>
<Card p="$5" maxWidth={360} width="100%">
<Stack gap="$3">
<Link to="/auth/create" style={{ textDecoration: "none" }}>
<Button width="100%" theme="active">
Create new wallet
</Button>
</Link>
<Link to="/auth/import" style={{ textDecoration: "none" }}>
<Button width="100%" variant="outlined">
Import existing
</Button>
</Link>
</Stack>
</Card>
</YStack>
)
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Mounts at /auth. Foundation router lazy-imports this module.
*
* /auth → Welcome (create or import)
* /auth/create → CreateMnemonic (display fresh BIP-39 phrase)
* /auth/confirm → ConfirmMnemonic (3-word quiz)
* /auth/import → ImportMnemonic (paste existing phrase)
* /auth/pin → SetPIN (6-digit setup)
* /auth/unlock → Unlock (returning users)
*/
import { Route, Routes } from "react-router-dom"
import Welcome from "./Welcome"
import CreateMnemonic from "./CreateMnemonic"
import ConfirmMnemonic from "./ConfirmMnemonic"
import ImportMnemonic from "./ImportMnemonic"
import SetPIN from "./SetPIN"
import Unlock from "./Unlock"
export default function AuthRoutes() {
return (
<Routes>
<Route index element={<Welcome />} />
<Route path="create" element={<CreateMnemonic />} />
<Route path="confirm" element={<ConfirmMnemonic />} />
<Route path="import" element={<ImportMnemonic />} />
<Route path="pin" element={<SetPIN />} />
<Route path="unlock" element={<Unlock />} />
</Routes>
)
}
export { Welcome, CreateMnemonic, ConfirmMnemonic, ImportMnemonic, SetPIN, Unlock }
@@ -0,0 +1,21 @@
/**
* Ephemeral mnemonic draft used during the create/import flow.
*
* Lives in memory ONLY — never persisted to localStorage. Cleared as soon
* as `auth.setCredentials` succeeds. If the user navigates away mid-flow
* the draft survives in RAM until the tab closes; that's acceptable since
* the mnemonic is not yet at-rest anywhere else.
*/
import { create } from "zustand"
interface MnemonicDraftState {
mnemonic: string | null
set: (m: string) => void
clear: () => void
}
export const useMnemonicDraft = create<MnemonicDraftState>((set) => ({
mnemonic: null,
set: (mnemonic) => set({ mnemonic }),
clear: () => set({ mnemonic: null }),
}))
@@ -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>
)
}
+23 -17
View File
@@ -1,24 +1,30 @@
/**
* Portfolio screen — Foundation placeholder.
* Portfolio module entry. Two integration shapes are supported:
*
* Owned by Auth-Portfolio Blue. This stub renders so the router builds clean
* before that Blue merges. Replace this file with the full SCREENS.md §1
* Account / Portfolio implementation.
* 1. Foundation router with `path: "portfolio"`. The default export
* renders <Portfolio> directly. Asset detail at `/portfolio/:address`
* is reachable when foundation upgrades to `path: "portfolio/*"`.
*
* 2. Foundation router with `path: "portfolio/*"`. The nested <Routes>
* picks up the index + detail.
*
* Either way, this module stays self-contained.
*/
import { useBrand } from "../../hooks/useBrand"
import { useAccount } from "../../hooks/useAccount"
import { Route, Routes } from "react-router-dom"
import Portfolio from "./Portfolio"
import AssetDetail from "./AssetDetail"
export default function Portfolio(): React.JSX.Element {
const brand = useBrand()
const account = useAccount()
export default function PortfolioRoutes() {
return (
<section>
<h1 style={{ fontSize: 24, marginBottom: 8 }}>Portfolio</h1>
<p style={{ color: "var(--neutral2, #888)" }}>
{account.connected
? `Connected: ${account.address}`
: `Welcome to ${brand.walletName || "Lux Wallet"}. Foundation placeholder — Auth-Portfolio Blue ships the real screen.`}
</p>
</section>
<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,78 @@
/**
* Per-LLM token balances on the Zoo chain.
*
* Zoo L1 emits a chain-specific token per model variant
* (ZEN4-NANO/MINI/LARGE/ULTRA). The contract addresses live in a Zoo-published
* registry; for now we hard-code the four canonical entries against Zoo
* mainnet (chainId 200200). White-labels can override via brand.json.
*
* Returns [] when not on a Zoo chain — keeping the consumer trivial.
*/
import { useEffect, useState } from "react"
import { createPublicClient, erc20Abi, formatUnits, http, type Address } from "viem"
import { getBootnodeRpcUrl } from "@luxfi/wallet-brand"
import type { TokenBalance } from "../../store/portfolio"
const ZOO_CHAIN_ID = 200200
interface PerLLMEntry {
address: Address
symbol: string
name: string
}
// Source: zoo-per-llm-chains paper §3 (canonical addresses).
// White-labels override via brand.runtime.perLLMTokens (Foundation slice).
const PER_LLM_TOKENS: PerLLMEntry[] = [
{ address: "0x0000000000000000000000000000000000004041", symbol: "ZEN4-NANO", name: "ZEN4 Nano" },
{ address: "0x0000000000000000000000000000000000004042", symbol: "ZEN4-MINI", name: "ZEN4 Mini" },
{ address: "0x0000000000000000000000000000000000004043", symbol: "ZEN4-LARGE", name: "ZEN4 Large" },
{ address: "0x0000000000000000000000000000000000004044", symbol: "ZEN4-ULTRA", name: "ZEN4 Ultra" },
]
export function usePerLLMTokens(chainId: number, address: Address | undefined): TokenBalance[] {
const [tokens, setTokens] = useState<TokenBalance[]>([])
useEffect(() => {
if (chainId !== ZOO_CHAIN_ID || !address) {
setTokens([])
return
}
const url = getBootnodeRpcUrl(chainId)
if (!url) return
let cancelled = false
const client = createPublicClient({ transport: http(url) })
Promise.all(
PER_LLM_TOKENS.map(async (t) => {
try {
const balance = await client.readContract({
address: t.address,
abi: erc20Abi,
functionName: "balanceOf",
args: [address],
})
return {
address: t.address,
symbol: t.symbol,
name: t.name,
decimals: 18,
balance: formatUnits(balance, 18),
} satisfies TokenBalance
} catch {
return null
}
}),
).then((results) => {
if (cancelled) return
setTokens(results.filter((r): r is TokenBalance => r !== null && Number(r.balance) > 0))
})
return () => {
cancelled = true
}
}, [chainId, address])
return tokens
}
@@ -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 }
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Auth slice. Holds the encrypted mnemonic envelope, the PIN verifier hash,
* and the in-memory unlock state.
*
* Threat model:
* - Mnemonic is the master secret. Never persist plaintext, never log,
* never analytics, never ship to any URL. Persisted form is AES-256-GCM
* ciphertext keyed off scrypt(PIN, salt).
* - PIN is verified via a SECOND, independent scrypt hash with its own
* salt — so a compromised verifier hash does not yield the encryption
* key. The two scrypt outputs are domain-separated.
* - `isUnlocked` and the in-memory mnemonic live ONLY in memory. Never
* persisted to localStorage. On tab refresh the user must re-enter PIN.
* - `lastUnlockedAt` is persisted (timestamp only) so the foundation
* router can implement idle auto-lock without us re-storing secrets.
*
* Persistence: zustand `persist` middleware writes to localStorage. Only
* the encrypted envelope and the PIN verifier hash leave RAM. The
* partialize() projection is the security boundary — review it carefully.
*/
import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware"
import { decryptMnemonic, encryptMnemonic, hashPIN, verifyPIN, type EncryptedEnvelope, type PinHash } from "../lib/crypto"
export interface AuthState {
/** AES-256-GCM ciphertext + nonce + KDF salt. Persisted. */
encryptedMnemonic: EncryptedEnvelope | null
/** scrypt verifier for the PIN, separate KDF salt from the encryption key. Persisted. */
pinHash: PinHash | null
/** True only while the tab session is unlocked. Never persisted. */
isUnlocked: boolean
/** Plaintext mnemonic in RAM while unlocked. Never persisted. */
mnemonic: string | null
/** Last successful unlock (epoch ms). Persisted (timestamp only). */
lastUnlockedAt: number | null
/** Initial setup — store the encrypted mnemonic and PIN verifier. */
setCredentials: (mnemonic: string, pin: string) => Promise<void>
/** Verify PIN, decrypt mnemonic, mark unlocked. */
unlock: (pin: string) => Promise<boolean>
/** Clear in-memory plaintext, keep persisted ciphertext. */
lock: () => void
/** Wipe everything — for "remove wallet" flows. */
reset: () => void
}
const INITIAL: Pick<AuthState, "encryptedMnemonic" | "pinHash" | "isUnlocked" | "mnemonic" | "lastUnlockedAt"> = {
encryptedMnemonic: null,
pinHash: null,
isUnlocked: false,
mnemonic: null,
lastUnlockedAt: null,
}
export const useAuth = create<AuthState>()(
persist(
(set, get) => ({
...INITIAL,
setCredentials: async (mnemonic, pin) => {
const encryptedMnemonic = await encryptMnemonic(mnemonic, pin)
const pinHash = await hashPIN(pin)
set({
encryptedMnemonic,
pinHash,
isUnlocked: true,
mnemonic,
lastUnlockedAt: Date.now(),
})
},
unlock: async (pin) => {
const { pinHash, encryptedMnemonic } = get()
if (!pinHash || !encryptedMnemonic) return false
const ok = await verifyPIN(pin, pinHash)
if (!ok) return false
const mnemonic = await decryptMnemonic(encryptedMnemonic, pin)
if (!mnemonic) return false
set({ isUnlocked: true, mnemonic, lastUnlockedAt: Date.now() })
return true
},
lock: () => set({ isUnlocked: false, mnemonic: null }),
reset: () => set({ ...INITIAL }),
}),
{
name: "lux-wallet-auth",
storage: createJSONStorage(() => localStorage),
// SECURITY: This is the persistence boundary. Only the at-rest fields
// leave RAM. `mnemonic` and `isUnlocked` are explicitly excluded.
partialize: (state) => ({
encryptedMnemonic: state.encryptedMnemonic,
pinHash: state.pinHash,
lastUnlockedAt: state.lastUnlockedAt,
}),
},
),
)
+53
View File
@@ -0,0 +1,53 @@
/**
* Portfolio slice. NOT persisted — balances re-fetch on unlock.
*
* Why no persist: balances are a function of (chain state, address) and we
* never want stale numbers to drive a Send/Swap. Treat them as a UI cache,
* never a source of truth.
*/
import { create } from "zustand"
export interface TokenBalance {
/** ERC-20 contract address, or "native" for chain-native asset. */
address: string
symbol: string
name: string
decimals: number
/** Raw on-chain balance, lowest-denomination, decimal string. */
balance: string
/** USD value at last fetch. Optional — price source may be unavailable. */
usd?: number
}
export interface ChainPortfolio {
chainId: number
/** Native asset balance (LUX, ZOO, etc), formatted in major units. */
native: TokenBalance
/** ERC-20 / ZRC-20 holdings on this chain. */
tokens: TokenBalance[]
}
export interface PortfolioState {
totalUSD: number
perChain: ChainPortfolio[]
lastFetched: number | null
isLoading: boolean
setPortfolio: (perChain: ChainPortfolio[], totalUSD: number) => void
setLoading: (loading: boolean) => void
reset: () => void
}
export const usePortfolio = create<PortfolioState>((set) => ({
totalUSD: 0,
perChain: [],
lastFetched: null,
isLoading: false,
setPortfolio: (perChain, totalUSD) =>
set({ perChain, totalUSD, lastFetched: Date.now(), isLoading: false }),
setLoading: (isLoading) => set({ isLoading }),
reset: () => set({ totalUSD: 0, perChain: [], lastFetched: null, isLoading: false }),
}))