mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +00:00
merge: wallet feat/auth-portfolio slice
# Conflicts: # apps/web/package.json # apps/web/src/screens/portfolio/index.tsx
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
// Fisher–Yates 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -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 }),
|
||||
}))
|
||||
Reference in New Issue
Block a user