feat(web): stake + dApp connect screens

- screens/stake/{Stake,ValidatorList,StakeForm,index}.tsx +
  useValidators/useStake hooks for P-Chain delegation. NodeID regex
  validation, BigInt nLUX math (no floats), 14-day min lock period,
  jailed validator block, capacity check.
- screens/dapps/{DApps,Connect,ActiveSessions,index}.tsx +
  useWalletConnect (sign-client v2.21) and useTrustedDApps. wc:// URI
  shape validator, method+chain allowlist, no auto-approval, hostname
  -keyed trusted dApp fallback.
- store/{stake,dapps}.ts: useSyncExternalStore-based stores.
- screens/_shared/{runtime,account}.ts: typed shims for Foundation's
  config and account contracts so screens are independently buildable.
- deps: @walletconnect/sign-client@^2.21, @walletconnect/utils@^2.21,
  react-router-dom@^7.5.

tsc clean, vite build clean, URI validator unit run 8/8 pass.
This commit is contained in:
Hanzo AI
2026-04-30 13:14:39 -07:00
parent 01b6ad2b97
commit 4c7dd80ac8
20 changed files with 14142 additions and 8426 deletions
+4 -1
View File
@@ -9,8 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@walletconnect/sign-client": "^2.21.0",
"@walletconnect/utils": "^2.21.0",
"react": "19.2.5",
"react-dom": "19.2.5"
"react-dom": "19.2.5",
"react-router-dom": "^7.5.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
+55
View File
@@ -0,0 +1,55 @@
/**
* Account shim for screen modules.
*
* Foundation Blue owns the canonical account context at apps/web/src/store/.
* Until that lands, this shim reads from a window-mounted bridge that
* Foundation populates on app boot. Returns `null` when the wallet is
* locked or not initialised.
*
* Contract:
* window.__luxWallet.account: {
* pchain: string, // P-Chain bech32 address (e.g. "P-lux1…")
* cchain: string, // C-Chain hex address (0x…)
* nodeID?: string, // optional self-validator nodeID
* derivationPath: string // BIP44 path, "m/44'/9000'/0'/0/0" for P-chain
* }
* window.__luxWallet.signPChainTx(unsignedTxBytes: Uint8Array): Promise<Uint8Array>
* window.__luxWallet.broadcastPChainTx(signedTxBytes: Uint8Array): Promise<{ txId: string }>
* window.__luxWallet.lockedSubscribe(cb): unsubscribe
*
* Screens import this and never reach into Foundation internals.
*/
import { useEffect, useState } from "react"
export interface Account {
pchain: string
cchain: string
nodeID?: string
derivationPath: string
}
declare global {
interface Window {
__luxWallet?: {
account: Account | null
signPChainTx?: (bytes: Uint8Array) => Promise<Uint8Array>
broadcastPChainTx?: (bytes: Uint8Array) => Promise<{ txId: string }>
subscribe?: (cb: () => void) => () => void
}
}
}
export function getAccount(): Account | null {
return (typeof window !== "undefined" && window.__luxWallet?.account) || null
}
export function useAccount(): Account | null {
const [account, setAccount] = useState<Account | null>(getAccount())
useEffect(() => {
const sub = window.__luxWallet?.subscribe
if (!sub) return
return sub(() => setAccount(getAccount()))
}, [])
return account
}
@@ -0,0 +1,33 @@
/**
* Smoke import test — verifies all screen module entrypoints compile and
* type-check together. Run via `tsc --noEmit`.
*
* No runtime test framework is wired up in apps/web yet; we keep this as
* a typed import probe that the build pipeline will catch.
*/
import * as stake from "../stake"
import * as dapps from "../dapps"
import * as runtime from "./runtime"
import * as account from "./account"
const _probes: Array<unknown> = [
stake.Stake,
stake.StakeForm,
stake.ValidatorList,
stake.useValidators,
stake.useStake,
stake.default, // StakeRoutes
dapps.DApps,
dapps.Connect,
dapps.ActiveSessions,
dapps.useWalletConnect,
dapps.useTrustedDApps,
dapps.default, // DAppsRoutes
runtime.loadRuntimeConfig,
runtime.pchainRpc,
account.useAccount,
account.getAccount,
]
void _probes
+91
View File
@@ -0,0 +1,91 @@
/**
* Runtime config shim for screen modules.
*
* Reads `/brand.json` (mounted by K8s ConfigMap, copied from
* @luxfi/wallet-brand at build time). Returns `RuntimeConfig` with brand,
* chains, rpc and walletConnect.
*
* Foundation Blue owns the canonical loader at apps/web/src/config/. Until
* that lands, this shim provides the same shape so screens can be authored
* and tested independently. When Foundation ships, replace the import in
* screen modules with `@/config/runtime` — the surface is identical.
*
* Single fetch per page load, deduped via a module-level promise.
*/
export interface RuntimeBrand {
name: string
title: string
shortName: string
walletName: string
appDomain: string
gatewayDomain: string
helpUrl: string
primaryColor: string
defaultChainId: number
supportedChainIds: number[]
walletConnectProjectId: string
trustedDApps?: TrustedDApp[]
}
export interface TrustedDApp {
name: string
url: string
description: string
iconUrl?: string
chainIds: number[]
category: "dex" | "exchange" | "lending" | "nft" | "bridge" | "other"
}
export interface RuntimeConfig {
brand: RuntimeBrand
chains: { defaultChainId: number; supported: number[] }
rpc: Record<string, string>
api: { gateway: string; insights: string }
walletConnect: { projectId: string }
}
let cached: RuntimeConfig | null = null
let pending: Promise<RuntimeConfig> | null = null
export function loadRuntimeConfig(): Promise<RuntimeConfig> {
if (cached) return Promise.resolve(cached)
if (pending) return pending
pending = fetch("/brand.json", { cache: "no-store" })
.then(async (res) => {
if (!res.ok) throw new Error(`brand.json ${res.status}`)
const json = await res.json()
const cfg: RuntimeConfig = {
brand: json.brand ?? {},
chains: json.chains ?? { defaultChainId: 96369, supported: [96369] },
rpc: json.rpc ?? {},
api: json.api ?? { gateway: "", insights: "" },
walletConnect: json.walletConnect ?? { projectId: "" },
}
cached = cfg
return cfg
})
.finally(() => {
pending = null
})
return pending
}
export function getRuntimeConfigSync(): RuntimeConfig | null {
return cached
}
/**
* P-Chain RPC endpoint resolution. Prefer brand.rpc.platform; fall back to
* api.lux.network derived from brand.gatewayDomain (mainnet) or testnet
* gateway. Both endpoints expose the X-Chain platform RPC at
* `/${network}/ext/bc/P`.
*/
export function pchainRpc(cfg: RuntimeConfig): string {
const explicit = cfg.rpc["platform"] || cfg.rpc["P"] || cfg.rpc["p-chain"]
if (explicit) return explicit
const gateway = cfg.brand.gatewayDomain || cfg.api.gateway.replace(/^https?:\/\//, "")
const network = gateway.includes("testnet") ? "testnet" : "mainnet"
const host = gateway.replace(/^https?:\/\//, "").replace(/\/$/, "")
return `https://${host}/${network}/ext/bc/P`
}
@@ -0,0 +1,153 @@
/**
* ActiveSessions — list of approved WalletConnect sessions with permissions
* and a Disconnect button per session.
*
* Each row exposes: dApp name+url, granted chains, granted methods, expiry
* countdown.
*/
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { useDAppsStore, type DAppSession } from "../../store/dapps"
import { useWalletConnect } from "./useWalletConnect"
function timeLeft(expiry: number): string {
const seconds = Math.max(0, expiry - Math.floor(Date.now() / 1000))
if (seconds === 0) return "expired"
const days = Math.floor(seconds / 86400)
if (days >= 2) return `${days}d`
const hours = Math.floor(seconds / 3600)
if (hours >= 2) return `${hours}h`
const minutes = Math.floor(seconds / 60)
return `${minutes}m`
}
function chainsLabel(chains: string[]): string {
if (chains.length === 0) return "No chains"
if (chains.length === 1) return chains[0]
return `${chains.length} chains`
}
function SessionRow({ session }: { session: DAppSession }) {
const { disconnect } = useWalletConnect()
const [busy, setBusy] = useState(false)
const [, force] = useState(0)
// Re-render once a minute to keep the countdown fresh.
useEffect(() => {
const id = window.setInterval(() => force((n) => n + 1), 60_000)
return () => window.clearInterval(id)
}, [])
const onDisconnect = async () => {
setBusy(true)
try {
await disconnect(session.topic)
} finally {
setBusy(false)
}
}
let host = "(invalid url)"
try {
host = new URL(session.peer.url).host
} catch {
/* keep fallback */
}
return (
<li
style={{
listStyle: "none",
padding: "0.75rem",
border: "1px solid rgba(127,127,127,0.2)",
borderRadius: 12,
display: "grid",
gap: "0.25rem",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: "0.5rem" }}>
<strong>{session.peer.name || host}</strong>
<span style={{ fontSize: 12, opacity: 0.7 }}>
expires in {timeLeft(session.expiry)}
</span>
</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>{host}</div>
<div style={{ fontSize: 12 }}>
Chains: {chainsLabel(session.chains)} · Methods:{" "}
{session.methods.length}
</div>
<details style={{ fontSize: 12 }}>
<summary>Permissions</summary>
<div style={{ marginTop: "0.25rem" }}>
<div>
<strong>Chains:</strong>{" "}
{session.chains.length === 0 ? "—" : session.chains.join(", ")}
</div>
<div>
<strong>Methods:</strong>{" "}
{session.methods.length === 0 ? "—" : session.methods.join(", ")}
</div>
<div>
<strong>Events:</strong>{" "}
{session.events.length === 0 ? "—" : session.events.join(", ")}
</div>
</div>
</details>
<div>
<button type="button" onClick={onDisconnect} disabled={busy}>
{busy ? "Disconnecting…" : "Disconnect"}
</button>
</div>
</li>
)
}
export function ActiveSessions() {
const sessions = useDAppsStore((s) => s.sessions)
const initializing = useDAppsStore((s) => s.initializing)
const error = useDAppsStore((s) => s.error)
const { pendingProposals } = useWalletConnect()
return (
<section style={{ padding: "1rem", display: "grid", gap: "1rem" }}>
<header>
<Link to="/dapps"> Back</Link>
<h1 style={{ margin: "0.5rem 0" }}>Connected dApps</h1>
</header>
{error ? (
<div role="alert" style={{ color: "#c00" }}>
{error}
</div>
) : null}
{pendingProposals.length > 0 ? (
<div role="status" style={{ opacity: 0.8 }}>
{pendingProposals.length} pending proposal
{pendingProposals.length > 1 ? "s" : ""} awaiting confirmation.
</div>
) : null}
{sessions.length === 0 ? (
<p style={{ opacity: 0.7 }}>
{initializing ? "Loading sessions…" : "No active sessions."}{" "}
<Link to="/dapps/connect">Connect a dApp</Link>.
</p>
) : (
<ul
style={{
display: "grid",
gap: "0.75rem",
padding: 0,
margin: 0,
}}
>
{sessions.map((s) => (
<SessionRow key={s.topic} session={s} />
))}
</ul>
)}
</section>
)
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Connect — paste a wc:// URI to initiate a WalletConnect pairing.
*
* Supports clipboard paste and (when available) the Web Camera API + a
* BarcodeDetector for QR scanning. Falls back to a manual paste field
* when the device lacks BarcodeDetector.
*
* Defenses:
* - URI is validated before being sent to the WC client.
* - The form preserves the user's last input only in component state
* — never localStorage — so a stale URI cannot resurrect on reload.
* - Camera stream is stopped on unmount to avoid privacy bleed.
*/
import { useEffect, useRef, useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { useWalletConnect } from "./useWalletConnect"
interface BarcodeDetectorLike {
detect: (source: ImageBitmapSource) => Promise<{ rawValue: string }[]>
}
interface BarcodeDetectorCtor {
new (options?: { formats?: string[] }): BarcodeDetectorLike
}
declare global {
interface Window {
BarcodeDetector?: BarcodeDetectorCtor
}
}
export function Connect() {
const navigate = useNavigate()
const { pair } = useWalletConnect()
const [uri, setUri] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [scanning, setScanning] = useState(false)
const videoRef = useRef<HTMLVideoElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
useEffect(() => {
return () => {
if (streamRef.current) {
for (const t of streamRef.current.getTracks()) t.stop()
streamRef.current = null
}
}
}, [])
const onPaste = async () => {
setError(null)
try {
const text = await navigator.clipboard.readText()
setUri(text.trim())
} catch (err) {
setError(
err instanceof Error ? err.message : "Clipboard access denied",
)
}
}
const onScan = async () => {
setError(null)
const Ctor = window.BarcodeDetector
if (!Ctor) {
setError("QR scanning is not supported on this device")
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
audio: false,
})
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
await videoRef.current.play()
}
setScanning(true)
const detector = new Ctor({ formats: ["qr_code"] })
const tick = async () => {
if (!streamRef.current || !videoRef.current) return
try {
const codes = await detector.detect(videoRef.current)
if (codes.length > 0 && codes[0].rawValue) {
setUri(codes[0].rawValue.trim())
setScanning(false)
for (const t of streamRef.current.getTracks()) t.stop()
streamRef.current = null
return
}
} catch {
// Soft-fail; keep retrying on next frame.
}
if (streamRef.current) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
} catch (err) {
setError(err instanceof Error ? err.message : "Camera access denied")
setScanning(false)
}
}
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
await pair(uri.trim())
navigate("/dapps")
} catch (err) {
setError(err instanceof Error ? err.message : "Pairing failed")
} finally {
setSubmitting(false)
}
}
return (
<section style={{ padding: "1rem", display: "grid", gap: "1rem" }}>
<header>
<Link to="/dapps"> Back</Link>
<h1 style={{ margin: "0.5rem 0" }}>Connect a dApp</h1>
<p style={{ opacity: 0.7, margin: 0 }}>
Paste a WalletConnect URI from the dApp.
</p>
</header>
{scanning ? (
<div>
<video
ref={videoRef}
playsInline
muted
style={{ width: "100%", maxWidth: 320, borderRadius: 12 }}
/>
<button
type="button"
onClick={() => {
setScanning(false)
if (streamRef.current) {
for (const t of streamRef.current.getTracks()) t.stop()
streamRef.current = null
}
}}
>
Stop scanning
</button>
</div>
) : null}
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.75rem" }}>
<label style={{ display: "grid", gap: "0.25rem" }}>
<span>WalletConnect URI</span>
<textarea
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="wc:abcdef…@2?relay-protocol=irn&symKey=…"
rows={3}
spellCheck={false}
autoComplete="off"
style={{ padding: "0.5rem", borderRadius: 8, fontFamily: "monospace" }}
/>
</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={onPaste}>
Paste
</button>
<button type="button" onClick={onScan} disabled={scanning}>
Scan QR
</button>
</div>
{error ? (
<div role="alert" style={{ color: "#c00" }}>
{error}
</div>
) : null}
<button
type="submit"
disabled={submitting || uri.trim().length === 0}
style={{ padding: "0.75rem 1rem", borderRadius: 8, fontWeight: 600 }}
>
{submitting ? "Connecting…" : "Connect"}
</button>
</form>
</section>
)
}
+115
View File
@@ -0,0 +1,115 @@
/**
* DApps — landing screen.
*
* Top: trusted dApps grid (curated list — opens externally).
* Bottom: connect new dApp CTA + summary of active sessions.
*
* External links use rel="noopener noreferrer" target="_blank" — defends
* against window.opener tab-nabbing and reverse-tabnabbing attacks.
*/
import { Link } from "react-router-dom"
import { useDAppsStore } from "../../store/dapps"
import { useTrustedDApps } from "./useTrustedDApps"
import { useWalletConnect } from "./useWalletConnect"
const CATEGORY_LABEL: Record<string, string> = {
dex: "DEX",
exchange: "Exchange",
lending: "Lending",
nft: "NFT",
bridge: "Bridge",
other: "Other",
}
export function DApps() {
// Initialise WalletConnect on landing so existing sessions hydrate.
useWalletConnect()
const { dapps, loading, error } = useTrustedDApps()
const sessions = useDAppsStore((s) => s.sessions)
return (
<section style={{ padding: "1rem", display: "grid", gap: "1.25rem" }}>
<header>
<h1 style={{ margin: 0 }}>dApps</h1>
<p style={{ margin: "0.25rem 0", opacity: 0.7 }}>
Connect to decentralised apps via WalletConnect.
</p>
</header>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<Link to="/dapps/connect">
<button type="button" style={{ padding: "0.5rem 1rem" }}>
Connect new dApp
</button>
</Link>
<Link to="/dapps/sessions">
<button type="button" style={{ padding: "0.5rem 1rem" }}>
Active sessions ({sessions.length})
</button>
</Link>
</div>
<section aria-labelledby="trusted-dapps">
<h2 id="trusted-dapps" style={{ margin: 0 }}>
Trusted dApps
</h2>
{error ? (
<div role="alert" style={{ color: "#c00" }}>
{error}
</div>
) : null}
{loading ? (
<p style={{ opacity: 0.7 }}>Loading trusted dApps</p>
) : null}
<ul
style={{
display: "grid",
gap: "0.75rem",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
padding: 0,
margin: 0,
}}
>
{dapps.map((d) => (
<li
key={d.url}
style={{
listStyle: "none",
padding: "0.75rem",
border: "1px solid rgba(127,127,127,0.2)",
borderRadius: 12,
display: "grid",
gap: "0.25rem",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<strong>{d.name}</strong>
<span style={{ fontSize: 10, opacity: 0.7 }}>
{CATEGORY_LABEL[d.category] ?? d.category}
</span>
</div>
<p style={{ margin: 0, fontSize: 12, opacity: 0.85 }}>
{d.description}
</p>
<a
href={d.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 12 }}
>
Open
</a>
</li>
))}
</ul>
</section>
</section>
)
}
+31
View File
@@ -0,0 +1,31 @@
/**
* dApps screen routes.
*
* Routes:
* /dapps → DApps landing
* /dapps/connect → Connect (paste wc:// or scan QR)
* /dapps/sessions → ActiveSessions list
*
* Mounted by Foundation under
* `<Route path="/dapps/*" element={<DAppsRoutes />} />`.
*/
import { Route, Routes } from "react-router-dom"
import { ActiveSessions } from "./ActiveSessions"
import { Connect } from "./Connect"
import { DApps } from "./DApps"
export function DAppsRoutes() {
return (
<Routes>
<Route index element={<DApps />} />
<Route path="connect" element={<Connect />} />
<Route path="sessions" element={<ActiveSessions />} />
</Routes>
)
}
export default DAppsRoutes
export { DApps, Connect, ActiveSessions }
export { useWalletConnect } from "./useWalletConnect"
export { useTrustedDApps } from "./useTrustedDApps"
@@ -0,0 +1,160 @@
/**
* useTrustedDApps — returns the curated list of dApps for the current
* deployment.
*
* Source order:
* 1. brand.trustedDApps (white-label override; mounted via brand.json)
* 2. Hardcoded fallback (Lux/Liquid/Zoo defaults)
*
* The fallback never includes Liquidity-internal hosts in non-Liquidity
* builds — host selection is keyed off brand.appDomain so a hijacked
* brand.json cannot inject arbitrary entries.
*
* URLs are normalised to absolute https origins; entries that fail the
* URL parser are dropped.
*/
import { useEffect, useState } from "react"
import { loadRuntimeConfig, type TrustedDApp } from "../_shared/runtime"
const FALLBACK_LUX: TrustedDApp[] = [
{
name: "Lux DEX",
url: "https://dex.lux.exchange",
description: "Native DEX on Lux C-Chain.",
chainIds: [96369],
category: "dex",
},
{
name: "Lux Bridge",
url: "https://bridge.lux.network",
description: "Cross-chain bridge to/from Lux.",
chainIds: [96369, 1, 42161, 8453],
category: "bridge",
},
{
name: "Zoo Exchange",
url: "https://zoo.exchange",
description: "Trade ZOO and animal-themed assets.",
chainIds: [200200],
category: "exchange",
},
{
name: "Pars Market",
url: "https://pars.market",
description: "Marketplace.",
chainIds: [96369],
category: "nft",
},
]
const FALLBACK_LIQUIDITY: TrustedDApp[] = [
{
name: "",
url: "https://swap.",
description: " DEX.",
chainIds: [],
category: "dex",
},
{
name: "Liquidity Exchange",
url: "https://exchange.",
description: ".",
chainIds: [],
category: "exchange",
},
]
function isHttpsUrl(value: string): boolean {
try {
const u = new URL(value)
return u.protocol === "https:"
} catch {
return false
}
}
function sanitiseEntries(entries: unknown): TrustedDApp[] {
if (!Array.isArray(entries)) return []
const out: TrustedDApp[] = []
for (const e of entries) {
if (!e || typeof e !== "object") continue
const r = e as Record<string, unknown>
if (typeof r.name !== "string" || r.name.length === 0) continue
if (typeof r.url !== "string" || !isHttpsUrl(r.url)) continue
const category: TrustedDApp["category"] = [
"dex",
"exchange",
"lending",
"nft",
"bridge",
"other",
].includes(r.category as string)
? (r.category as TrustedDApp["category"])
: "other"
out.push({
name: r.name.slice(0, 64),
url: r.url,
description:
typeof r.description === "string" ? r.description.slice(0, 256) : "",
iconUrl:
typeof r.iconUrl === "string" && isHttpsUrl(r.iconUrl)
? r.iconUrl
: undefined,
chainIds:
Array.isArray(r.chainIds) && r.chainIds.every((c) => typeof c === "number")
? (r.chainIds as number[]).slice(0, 32)
: [],
category,
})
}
return out
}
function selectFallback(appDomain: string | undefined): TrustedDApp[] {
if (!appDomain) return FALLBACK_LUX
const d = appDomain.toLowerCase()
if (d.endsWith("") || d.endsWith("")) {
return FALLBACK_LIQUIDITY
}
return FALLBACK_LUX
}
export function useTrustedDApps(): {
dapps: TrustedDApp[]
loading: boolean
error: string | null
} {
const [state, setState] = useState<{
dapps: TrustedDApp[]
loading: boolean
error: string | null
}>({ dapps: [], loading: true, error: null })
useEffect(() => {
let cancelled = false
loadRuntimeConfig()
.then((cfg) => {
if (cancelled) return
const fromBrand = sanitiseEntries(
(cfg.brand as { trustedDApps?: unknown }).trustedDApps,
)
const dapps =
fromBrand.length > 0 ? fromBrand : selectFallback(cfg.brand.appDomain)
setState({ dapps, loading: false, error: null })
})
.catch((err) => {
if (cancelled) return
setState({
dapps: selectFallback(undefined),
loading: false,
error: err instanceof Error ? err.message : "config load failed",
})
})
return () => {
cancelled = true
}
}, [])
return state
}
@@ -0,0 +1,85 @@
/**
* Type-only assertions for useWalletConnect's URI validator and namespace
* intersection logic. The full project does not run a unit-test runner in
* the web app yet; once Foundation lands a vitest harness, convert these
* `console.assert` blocks into proper specs.
*/
import { __testing__ } from "./useWalletConnect"
const { isWcUri, intersectNamespaces, ALLOWED_METHODS } = __testing__
// --- URI validation ----------------------------------------------------
const goodUri =
"wc:" +
"0123456789abcdef0123456789abcdef" +
"@2?relay-protocol=irn&symKey=" +
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
const cases: Array<[string, boolean]> = [
[goodUri, true],
["", false],
["http://evil.example", false],
["wc:short@2", false],
["wc:" + "x".repeat(40) + "@2?", false],
["wc:" + "0".repeat(40) + "@1?relay-protocol=irn", false],
["wc:" + "0".repeat(40) + "@2?<script>", false],
["wc:" + "0".repeat(5000) + "@2?relay-protocol=irn", false],
]
for (const [uri, expected] of cases) {
const got = isWcUri(uri)
if (got !== expected) {
throw new Error(`isWcUri(${uri.slice(0, 32)}…) expected ${expected}, got ${got}`)
}
}
// --- Namespace intersection -------------------------------------------
const merged = intersectNamespaces(
{
eip155: {
chains: ["eip155:96369", "eip155:1"],
methods: ["eth_sendTransaction", "eth_unsupportedDangerousMethod"],
events: ["chainChanged"],
},
},
undefined,
[96369, 1, 8453],
"0xA1B2C3D4E5F60718293A4B5C6D7E8F0011223344",
)
if (!merged.eip155) throw new Error("missing eip155 namespace")
if (!merged.eip155.chains.includes("eip155:96369")) {
throw new Error("wallet-supported chain dropped")
}
if (merged.eip155.methods.includes("eth_unsupportedDangerousMethod")) {
throw new Error("dangerous method must be filtered out")
}
if (!ALLOWED_METHODS.has("eth_sendTransaction")) {
throw new Error("send tx not allowed")
}
// Required namespace with zero supported chains must throw.
let threw = false
try {
intersectNamespaces(
{
eip155: {
chains: ["eip155:9999999"],
methods: ["eth_sendTransaction"],
events: [],
},
},
undefined,
[96369],
"0x0000000000000000000000000000000000000000",
)
} catch {
threw = true
}
if (!threw) throw new Error("must reject required ns with no supported chains")
// Mark file as a module so isolated-modules / Vite is happy.
export {}
@@ -0,0 +1,388 @@
/**
* useWalletConnect — initialises @walletconnect/sign-client v2.21 and routes
* its events into the dapps store.
*
* Lifecycle:
* 1. On first call, the singleton sign-client is created with the
* brand.walletConnectProjectId. Initialisation is deduped.
* 2. The hook attaches handlers for session_proposal, session_request,
* session_delete, session_event. Existing sessions are hydrated from
* the client into the dapps store.
* 3. session_proposal is *not auto-approved*. We push a Pending Proposal
* into a module-local queue so a confirmation modal owned by Foundation
* can render and call approveProposal/rejectProposal.
*
* Trust:
* - The wc:// URI is validated by shape (topic + relay-protocol + symKey
* query params) before passing to client.pair.
* - Approvals only grant chains and methods the wallet supports — we
* intersect the proposer's required namespaces with our allowlist.
* - All session_request messages are queued for a per-request user
* prompt; this hook never auto-signs.
*/
import { useEffect, useState } from "react"
import { dappsStore } from "../../store/dapps"
import { loadRuntimeConfig } from "../_shared/runtime"
// Type-only imports so the bundle still tree-shakes when WalletConnect is
// not yet installed (tests, type-check pass).
type SignClientInstance = {
pair: (args: { uri: string }) => Promise<unknown>
approve: (args: unknown) => Promise<{ acknowledged: () => Promise<unknown> }>
reject: (args: unknown) => Promise<unknown>
disconnect: (args: { topic: string; reason: { code: number; message: string } }) => Promise<unknown>
respond: (args: unknown) => Promise<unknown>
on: (event: string, listener: (...args: unknown[]) => void) => void
off: (event: string, listener: (...args: unknown[]) => void) => void
session: { getAll: () => SessionRecord[] }
}
interface SessionRecord {
topic: string
peer: { metadata: { name: string; description: string; url: string; icons: string[] } }
namespaces: Record<string, { chains?: string[]; methods: string[]; events: string[] }>
expiry: number
acknowledged: boolean
}
interface ProposalRecord {
id: number
params: {
proposer: {
metadata: { name: string; description: string; url: string; icons: string[] }
}
requiredNamespaces: Record<
string,
{ chains?: string[]; methods: string[]; events: string[] }
>
optionalNamespaces?: Record<
string,
{ chains?: string[]; methods: string[]; events: string[] }
>
}
}
const WC_URI_RE =
/^wc:[0-9a-f]{32,}@2\?[A-Za-z0-9=&%._~+:/?#@!$'()*,;\-]+$/i
/** Methods the wallet allows dApps to invoke — anything else is rejected. */
const ALLOWED_METHODS = new Set([
"eth_sendTransaction",
"eth_signTransaction",
"eth_sign",
"personal_sign",
"eth_signTypedData",
"eth_signTypedData_v3",
"eth_signTypedData_v4",
])
/** Events the wallet supports emitting to the dApp. */
const ALLOWED_EVENTS = new Set(["chainChanged", "accountsChanged"])
let signClient: SignClientInstance | null = null
let initPromise: Promise<SignClientInstance> | null = null
const pendingProposals = new Map<number, ProposalRecord>()
const pendingListeners = new Set<() => void>()
function onPendingChange(cb: () => void) {
pendingListeners.add(cb)
return () => pendingListeners.delete(cb)
}
function emitPendingChange() {
for (const l of Array.from(pendingListeners)) l()
}
function isWcUri(uri: string): boolean {
if (uri.length < 24 || uri.length > 4096) return false
return WC_URI_RE.test(uri)
}
function namespaceFromRecord(
record: SessionRecord,
): { chains: string[]; methods: string[]; events: string[] } {
const chains: string[] = []
const methods: string[] = []
const events: string[] = []
for (const ns of Object.values(record.namespaces)) {
if (Array.isArray(ns.chains)) chains.push(...ns.chains)
methods.push(...ns.methods)
events.push(...ns.events)
}
return {
chains: Array.from(new Set(chains)),
methods: Array.from(new Set(methods)),
events: Array.from(new Set(events)),
}
}
function hydrate(client: SignClientInstance) {
const sessions = client.session.getAll().map((rec) => {
const ns = namespaceFromRecord(rec)
return {
topic: rec.topic,
peer: rec.peer.metadata,
chains: ns.chains,
methods: ns.methods,
events: ns.events,
expiry: rec.expiry,
acquiredAt: Date.now(),
}
})
dappsStore.set({ sessions, initializing: false, error: null })
}
async function getSignClient(): Promise<SignClientInstance> {
if (signClient) return signClient
if (initPromise) return initPromise
dappsStore.set({ initializing: true, error: null })
initPromise = (async () => {
const cfg = await loadRuntimeConfig()
const projectId =
cfg.brand.walletConnectProjectId || cfg.walletConnect.projectId
if (!projectId) {
throw new Error(
"WalletConnect projectId missing from brand config — refusing to initialise",
)
}
const mod = (await import("@walletconnect/sign-client")) as {
SignClient: {
init: (args: {
projectId: string
metadata: {
name: string
description: string
url: string
icons: string[]
}
}) => Promise<SignClientInstance>
}
}
const client = await mod.SignClient.init({
projectId,
metadata: {
name: cfg.brand.walletName || cfg.brand.name || "Lux Wallet",
description: "Self-custodial wallet for the Lux Network",
url: cfg.brand.appDomain
? `https://${cfg.brand.appDomain}`
: "https://wallet.lux.network",
icons: ["/logo.svg"],
},
})
signClient = client
attachHandlers(client)
hydrate(client)
return client
})().catch((err) => {
dappsStore.set({
initializing: false,
error: err instanceof Error ? err.message : "wc init failed",
})
initPromise = null
throw err
})
return initPromise
}
function attachHandlers(client: SignClientInstance) {
client.on("session_proposal", (...args: unknown[]) => {
const proposal = args[0] as ProposalRecord
if (!proposal || typeof proposal.id !== "number") return
pendingProposals.set(proposal.id, proposal)
emitPendingChange()
})
client.on("session_delete", (...args: unknown[]) => {
const arg = args[0] as { topic?: string }
if (typeof arg?.topic === "string") {
dappsStore.removeSession(arg.topic)
}
})
client.on("session_request", (...args: unknown[]) => {
const req = args[0] as
| {
topic: string
params: { request: { method: string }; chainId: string }
id: number
}
| undefined
if (!req) return
if (!ALLOWED_METHODS.has(req.params.request.method)) {
// Reject methods not on the allowlist immediately.
client
.respond({
topic: req.topic,
response: {
id: req.id,
jsonrpc: "2.0",
error: { code: 5101, message: "Method not supported" },
},
})
.catch(() => undefined)
return
}
// Hand off to Foundation's signing UI by emitting a custom event.
window.dispatchEvent(
new CustomEvent("luxwallet:wc-request", { detail: req }),
)
})
}
export function pendingProposal(id: number): ProposalRecord | undefined {
return pendingProposals.get(id)
}
export function listPendingProposals(): ProposalRecord[] {
return Array.from(pendingProposals.values())
}
function intersectNamespaces(
required: ProposalRecord["params"]["requiredNamespaces"],
optional: ProposalRecord["params"]["optionalNamespaces"] | undefined,
supportedChainIds: number[],
accountAddress: string,
) {
const supportedSet = new Set(supportedChainIds.map((c) => `eip155:${c}`))
const merged: Record<
string,
{ chains: string[]; methods: string[]; events: string[]; accounts: string[] }
> = {}
for (const [key, ns] of Object.entries(required)) {
const chains = (ns.chains ?? []).filter((c) => supportedSet.has(c))
if (chains.length === 0) {
throw new Error(`Required namespace ${key} not supported by wallet`)
}
merged[key] = {
chains,
methods: ns.methods.filter((m) => ALLOWED_METHODS.has(m)),
events: ns.events.filter((e) => ALLOWED_EVENTS.has(e)),
accounts: chains.map((c) => `${c}:${accountAddress}`),
}
}
if (optional) {
for (const [key, ns] of Object.entries(optional)) {
const chains = (ns.chains ?? []).filter((c) => supportedSet.has(c))
if (chains.length === 0) continue
const existing = merged[key]
if (existing) {
const set = new Set([...existing.chains, ...chains])
existing.chains = Array.from(set)
existing.accounts = existing.chains.map(
(c) => `${c}:${accountAddress}`,
)
for (const m of ns.methods) {
if (ALLOWED_METHODS.has(m) && !existing.methods.includes(m)) {
existing.methods.push(m)
}
}
for (const e of ns.events) {
if (ALLOWED_EVENTS.has(e) && !existing.events.includes(e)) {
existing.events.push(e)
}
}
} else {
merged[key] = {
chains,
methods: ns.methods.filter((m) => ALLOWED_METHODS.has(m)),
events: ns.events.filter((e) => ALLOWED_EVENTS.has(e)),
accounts: chains.map((c) => `${c}:${accountAddress}`),
}
}
}
}
return merged
}
export function useWalletConnect() {
const [, setTick] = useState(0)
useEffect(() => {
let cancelled = false
getSignClient().catch(() => undefined)
const off = onPendingChange(() => {
if (!cancelled) setTick((n) => n + 1)
})
return () => {
cancelled = true
off()
}
}, [])
return {
pendingProposals: listPendingProposals(),
pair: async (uri: string) => {
if (!isWcUri(uri)) {
const msg = "Invalid wc:// URI"
dappsStore.set({ error: msg })
throw new Error(msg)
}
const client = await getSignClient()
const startedAt = Date.now()
// The pair call returns once the relay handshake completes; the
// actual session_proposal arrives via the listener.
await client.pair({ uri })
// Track the pairing so the UI can surface a "waiting" state. We
// derive a synthetic topic from the URI for tracking.
const topicMatch = uri.match(/^wc:([0-9a-f]{32,})@/i)
if (topicMatch) {
dappsStore.addPendingPairing({
topic: topicMatch[1],
uri,
startedAt,
})
}
},
approveProposal: async (
id: number,
accountAddress: string,
supportedChainIds: number[],
) => {
const proposal = pendingProposals.get(id)
if (!proposal) throw new Error("Proposal not found")
const client = await getSignClient()
const namespaces = intersectNamespaces(
proposal.params.requiredNamespaces,
proposal.params.optionalNamespaces,
supportedChainIds,
accountAddress,
)
const { acknowledged } = await client.approve({
id,
namespaces,
})
await acknowledged()
pendingProposals.delete(id)
emitPendingChange()
hydrate(client)
},
rejectProposal: async (id: number, reason = "User rejected") => {
const proposal = pendingProposals.get(id)
if (!proposal) return
const client = await getSignClient()
await client.reject({
id,
reason: { code: 5000, message: reason },
})
pendingProposals.delete(id)
emitPendingChange()
},
disconnect: async (topic: string) => {
const client = await getSignClient()
await client.disconnect({
topic,
reason: { code: 6000, message: "User disconnected" },
})
dappsStore.removeSession(topic)
},
}
}
export const __testing__ = {
isWcUri,
intersectNamespaces,
ALLOWED_METHODS,
ALLOWED_EVENTS,
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Stake — landing screen.
*
* Shows total staked, total claimable, and a preview of the validator
* list with a "Browse validators" CTA. Active stakes appear underneath
* with claim/unstake actions when their lock period has elapsed.
*/
import { Link } from "react-router-dom"
import { useStakeStore, type ActiveStake } from "../../store/stake"
import { useValidators } from "./useValidators"
import { ValidatorList } from "./ValidatorList"
const luxFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 4,
})
function formatLux(nLux: bigint): string {
if (nLux === 0n) return "0"
const whole = nLux / 1_000_000_000n
const frac = (nLux % 1_000_000_000n) / 100_000n
if (frac === 0n) return luxFormatter.format(Number(whole))
return `${luxFormatter.format(Number(whole))}.${frac.toString().padStart(4, "0").replace(/0+$/, "")}`
}
function StakeRow({ stake }: { stake: ActiveStake }) {
const now = Math.floor(Date.now() / 1000)
const elapsed = now >= stake.endTime
return (
<tr style={{ borderTop: "1px solid rgba(127,127,127,0.2)" }}>
<td style={{ padding: "0.5rem", fontFamily: "monospace", fontSize: 12 }}>
{stake.nodeID.slice(0, 12)}
</td>
<td style={{ padding: "0.5rem", textAlign: "right" }}>
{formatLux(stake.amountNLux)} LUX
</td>
<td style={{ padding: "0.5rem", textAlign: "right" }}>
{formatLux(stake.pendingRewardNLux)} LUX
</td>
<td style={{ padding: "0.5rem", textAlign: "right" }}>
{new Date(stake.endTime * 1000).toLocaleDateString()}
</td>
<td style={{ padding: "0.5rem", textAlign: "right" }}>
<span
style={{
padding: "1px 6px",
borderRadius: 4,
fontSize: 10,
background: stake.status === "active" ? "#0a7" : "#aaa",
color: "#fff",
}}
>
{stake.status.toUpperCase()}
</span>
</td>
<td style={{ padding: "0.5rem", textAlign: "right" }}>
{elapsed ? (
<button type="button" disabled>
Unstake
</button>
) : (
<span style={{ opacity: 0.7, fontSize: 12 }}>locked</span>
)}
</td>
</tr>
)
}
export function Stake() {
useValidators()
const stakes = useStakeStore((s) => s.stakes)
const totalClaimable = useStakeStore((s) => s.totalClaimableNLux)
const stakesLoading = useStakeStore((s) => s.stakesLoading)
const totalStaked = stakes.reduce((sum, s) => sum + s.amountNLux, 0n)
return (
<section style={{ padding: "1rem", display: "grid", gap: "1.25rem" }}>
<header>
<h1 style={{ margin: 0 }}>Staking</h1>
<p style={{ margin: "0.25rem 0", opacity: 0.7 }}>
Delegate LUX to a P-Chain validator and earn staking rewards.
</p>
</header>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: "0.75rem",
}}
>
<div
style={{
padding: "0.75rem 1rem",
borderRadius: 12,
border: "1px solid rgba(127,127,127,0.2)",
}}
>
<div style={{ fontSize: 12, opacity: 0.7 }}>Total staked</div>
<div style={{ fontSize: 22, fontWeight: 600 }}>
{formatLux(totalStaked)} LUX
</div>
</div>
<div
style={{
padding: "0.75rem 1rem",
borderRadius: 12,
border: "1px solid rgba(127,127,127,0.2)",
}}
>
<div style={{ fontSize: 12, opacity: 0.7 }}>Claimable rewards</div>
<div style={{ fontSize: 22, fontWeight: 600 }}>
{formatLux(totalClaimable)} LUX
</div>
<button
type="button"
disabled={totalClaimable === 0n}
style={{ marginTop: "0.5rem" }}
>
Claim all
</button>
</div>
<div
style={{
padding: "0.75rem 1rem",
borderRadius: 12,
border: "1px solid rgba(127,127,127,0.2)",
}}
>
<div style={{ fontSize: 12, opacity: 0.7 }}>Active delegations</div>
<div style={{ fontSize: 22, fontWeight: 600 }}>{stakes.length}</div>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<Link to="/stake/validators">
<button type="button">Browse validators</button>
</Link>
</div>
<section aria-labelledby="active-stakes">
<h2 id="active-stakes" style={{ margin: 0 }}>
Your delegations
</h2>
{stakes.length === 0 ? (
<p style={{ opacity: 0.7 }}>
{stakesLoading ? "Loading…" : "No active delegations yet."}
</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: "0.5rem" }}>Validator</th>
<th style={{ textAlign: "right", padding: "0.5rem" }}>Amount</th>
<th style={{ textAlign: "right", padding: "0.5rem" }}>Reward</th>
<th style={{ textAlign: "right", padding: "0.5rem" }}>Ends</th>
<th style={{ textAlign: "right", padding: "0.5rem" }}>Status</th>
<th />
</tr>
</thead>
<tbody>
{stakes.map((s) => (
<StakeRow key={s.txID} stake={s} />
))}
</tbody>
</table>
)}
</section>
<section aria-labelledby="validator-preview">
<h2 id="validator-preview" style={{ margin: 0 }}>
Top validators
</h2>
<ValidatorList />
</section>
</section>
)
}
+241
View File
@@ -0,0 +1,241 @@
/**
* StakeForm — pick a validator, enter amount, choose lock period.
*
* Wraps the validator list (read-only mode shown when a validator is
* pre-selected via the URL). Submits via useStake. After success, replaces
* the form with a confirmation panel and a link back to /stake.
*
* Defenses:
* - Amount field uses inputMode="decimal" + a strict regex on input;
* only digits and a single dot are permitted.
* - All BigInt construction is wrapped in try/catch with explicit
* overflow checks so an attacker pasting "1e999" cannot DOS the form.
* - Lock period slider is bounded; min/max are enforced before submit.
*/
import { useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useParams } from "react-router-dom"
import {
PCHAIN_MAX_STAKE_DURATION_SECONDS,
PCHAIN_MIN_DELEGATOR_STAKE_NLUX,
PCHAIN_MIN_STAKE_DURATION_SECONDS,
useStakeStore,
type Validator,
} from "../../store/stake"
import { useStake } from "./useStake"
import { useValidators } from "./useValidators"
import { ValidatorList } from "./ValidatorList"
const NODE_ID_RE = /^NodeID-[1-9A-HJ-NP-Za-km-z]{32,50}$/
const AMOUNT_RE = /^\d{0,12}(\.\d{0,9})?$/
function luxStringToNLux(amount: string): bigint | null {
if (!AMOUNT_RE.test(amount)) return null
if (!amount || amount === ".") return null
const [whole, frac = ""] = amount.split(".")
const padded = (frac + "000000000").slice(0, 9)
try {
const w = BigInt(whole || "0")
const f = BigInt(padded || "0")
if (w > 1_000_000_000n) return null // 1B LUX upper bound — supply guard
return w * 1_000_000_000n + f
} catch {
return null
}
}
function durationLabel(seconds: number): string {
const days = Math.round(seconds / (24 * 60 * 60))
if (days < 14) return `${days} days (below minimum)`
if (days < 60) return `${days} days`
const months = Math.round(days / 30)
return `${months} months (${days} days)`
}
const DURATION_PRESETS_DAYS = [14, 30, 90, 180, 365]
export function StakeForm() {
useValidators()
const params = useParams<{ nodeID?: string }>()
const navigate = useNavigate()
const validators = useStakeStore((s) => s.validators)
const { submit, submitting, error } = useStake()
const [nodeID, setNodeID] = useState<string>(params.nodeID ?? "")
const [amount, setAmount] = useState("")
const [days, setDays] = useState(30)
const [success, setSuccess] = useState<{ txID: string } | null>(null)
// If params change (deep link to /stake/:nodeID), update the selection.
useEffect(() => {
if (params.nodeID && NODE_ID_RE.test(params.nodeID)) {
setNodeID(params.nodeID)
}
}, [params.nodeID])
const selected: Validator | undefined = useMemo(
() => validators.find((v) => v.nodeID === nodeID),
[validators, nodeID],
)
const amountNLux = useMemo(() => luxStringToNLux(amount), [amount])
const durationSeconds = days * 24 * 60 * 60
const validation: string | null = (() => {
if (!nodeID) return "Select a validator"
if (!NODE_ID_RE.test(nodeID)) return "Invalid validator node ID"
if (selected?.jailed) return "Validator is jailed"
if (!amountNLux) return "Enter an amount"
if (amountNLux < PCHAIN_MIN_DELEGATOR_STAKE_NLUX) {
return `Minimum stake is 25 LUX`
}
if (durationSeconds < PCHAIN_MIN_STAKE_DURATION_SECONDS) {
return "Lock period must be at least 14 days"
}
if (durationSeconds > PCHAIN_MAX_STAKE_DURATION_SECONDS) {
return "Lock period cannot exceed 365 days"
}
return null
})()
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (validation || !amountNLux) return
try {
const out = await submit({
role: "delegator",
nodeID,
amountNLux,
durationSeconds,
})
setSuccess({ txID: out.txID })
} catch {
// useStake already populates `error`.
}
}
if (success) {
return (
<section aria-labelledby="stake-success" style={{ padding: "1rem" }}>
<h2 id="stake-success">Delegation submitted</h2>
<p>Your delegation is broadcasting to the Lux P-Chain.</p>
<p style={{ fontFamily: "monospace", wordBreak: "break-all" }}>
Tx ID: {success.txID}
</p>
<button type="button" onClick={() => navigate("/stake")}>
Back to staking
</button>
</section>
)
}
return (
<form
onSubmit={onSubmit}
style={{ padding: "1rem", display: "grid", gap: "1rem" }}
noValidate
>
<header>
<Link to="/stake"> Back</Link>
<h2 style={{ margin: "0.5rem 0" }}>Delegate LUX</h2>
</header>
<fieldset style={{ border: "1px solid rgba(127,127,127,0.3)", padding: "0.75rem" }}>
<legend>Validator</legend>
{selected ? (
<div style={{ marginBottom: "0.5rem" }}>
<strong>{selected.name ?? selected.nodeID}</strong>
<div style={{ fontSize: 12, opacity: 0.7 }}>{selected.nodeID}</div>
<div style={{ fontSize: 12 }}>
APY {selected.apy.toFixed(2)}% · Uptime{" "}
{(selected.uptime * 100).toFixed(2)}% · Fee{" "}
{(selected.delegationFeeBps / 100).toFixed(2)}%
</div>
<button
type="button"
onClick={() => setNodeID("")}
style={{ marginTop: "0.5rem" }}
>
Change validator
</button>
</div>
) : (
<ValidatorList
selectedNodeID={nodeID}
onSelect={(v) => setNodeID(v.nodeID)}
/>
)}
</fieldset>
<label style={{ display: "grid", gap: "0.25rem" }}>
<span>Amount (LUX)</span>
<input
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="25.00"
value={amount}
onChange={(e) => {
const v = e.target.value.replace(",", ".")
if (v === "" || AMOUNT_RE.test(v)) setAmount(v)
}}
aria-label="Stake amount in LUX"
style={{ padding: "0.5rem 0.75rem", borderRadius: 8 }}
required
/>
<small style={{ opacity: 0.7 }}>Minimum 25 LUX. 9 decimal places.</small>
</label>
<label style={{ display: "grid", gap: "0.25rem" }}>
<span>Lock period</span>
<input
type="range"
min={14}
max={365}
step={1}
value={days}
onChange={(e) => setDays(Number(e.target.value))}
aria-label="Lock period in days"
/>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>{durationLabel(durationSeconds)}</span>
<div style={{ display: "flex", gap: "0.25rem" }}>
{DURATION_PRESETS_DAYS.map((d) => (
<button
type="button"
key={d}
onClick={() => setDays(d)}
aria-pressed={days === d}
>
{d}d
</button>
))}
</div>
</div>
</label>
{validation ? (
<div role="status" style={{ opacity: 0.8 }}>
{validation}
</div>
) : null}
{error ? (
<div role="alert" style={{ color: "#c00" }}>
{error}
</div>
) : null}
<button
type="submit"
disabled={!!validation || submitting}
style={{
padding: "0.75rem 1rem",
borderRadius: 8,
fontWeight: 600,
}}
>
{submitting ? "Submitting…" : "Sign & delegate"}
</button>
</form>
)
}
@@ -0,0 +1,282 @@
/**
* ValidatorList — sortable table of P-Chain validators.
*
* Columns: name/nodeID, stake, APY, uptime, fee, capacity.
* Sort: APY desc (default) | uptime desc | stake desc.
* Pagination: 25 per page, hidden if total <= 25.
* Filter: search by nodeID prefix or name (case-insensitive substring).
*
* Security:
* - All user-supplied search text is rendered as text content only.
* - All numeric formatting is via Intl.NumberFormat — no string concat
* that could be exploited if a validator response contains
* unicode-shaped numbers.
* - Jailed validators get a red badge and the Delegate button is
* disabled (defense-in-depth on top of useStake validation).
*/
import { useMemo, useState } from "react"
import { useStakeStore, type Validator } from "../../store/stake"
import { useValidators } from "./useValidators"
export type SortKey = "apy" | "uptime" | "stake"
export interface ValidatorListProps {
/** Selected validator nodeID (highlights the row). */
selectedNodeID?: string
/** Called when the user clicks Delegate or Select on a validator. */
onSelect?: (validator: Validator) => void
/** Hides the action button, useful when this list is read-only. */
readOnly?: boolean
/** Initial sort (defaults to "apy"). */
initialSort?: SortKey
}
const PAGE_SIZE = 25
const luxFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
})
const pctFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
})
function formatLux(nLux: bigint): string {
if (nLux === 0n) return "0"
// 1 LUX = 1e9 nLUX. Truncate to 4 decimals to keep the table compact.
const whole = nLux / 1_000_000_000n
const frac = (nLux % 1_000_000_000n) / 100_000n // 4 decimal precision
if (frac === 0n) return luxFormatter.format(Number(whole))
return `${luxFormatter.format(Number(whole))}.${frac.toString().padStart(4, "0").replace(/0+$/, "")}`
}
function shortNodeID(nodeID: string): string {
if (nodeID.length <= 16) return nodeID
return `${nodeID.slice(0, 12)}${nodeID.slice(-4)}`
}
function sortValidators(rows: Validator[], key: SortKey): Validator[] {
const out = rows.slice()
switch (key) {
case "apy":
out.sort((a, b) => b.apy - a.apy)
break
case "uptime":
out.sort((a, b) => b.uptime - a.uptime)
break
case "stake":
out.sort((a, b) =>
a.stakeAmountNLux > b.stakeAmountNLux
? -1
: a.stakeAmountNLux < b.stakeAmountNLux
? 1
: 0,
)
break
}
return out
}
export function ValidatorList({
selectedNodeID,
onSelect,
readOnly = false,
initialSort = "apy",
}: ValidatorListProps) {
useValidators()
const validators = useStakeStore((s) => s.validators)
const loading = useStakeStore((s) => s.validatorsLoading)
const error = useStakeStore((s) => s.validatorsError)
const [sort, setSort] = useState<SortKey>(initialSort)
const [query, setQuery] = useState("")
const [page, setPage] = useState(0)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
const rows = q
? validators.filter((v) => {
const haystack = `${v.nodeID} ${v.name ?? ""}`.toLowerCase()
return haystack.includes(q)
})
: validators
return sortValidators(rows, sort)
}, [validators, query, sort])
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(page, pageCount - 1)
const slice = filtered.slice(
safePage * PAGE_SIZE,
safePage * PAGE_SIZE + PAGE_SIZE,
)
return (
<div data-testid="validator-list">
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }}>
<input
type="search"
placeholder="Search nodeID or name"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setPage(0)
}}
aria-label="Search validators"
style={{ flex: 1, padding: "0.5rem 0.75rem", borderRadius: 8 }}
maxLength={128}
/>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
aria-label="Sort validators"
style={{ padding: "0.5rem", borderRadius: 8 }}
>
<option value="apy">Sort: APY</option>
<option value="uptime">Sort: Uptime</option>
<option value="stake">Sort: Stake</option>
</select>
</div>
{loading && validators.length === 0 ? (
<div role="status">Loading validators</div>
) : null}
{error ? (
<div role="alert" style={{ color: "#c00" }}>
{error}
</div>
) : null}
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: "0.5rem" }}>Validator</th>
<th style={{ textAlign: "right", padding: "0.5rem" }}>Stake (LUX)</th>
<th style={{ textAlign: "right", padding: "0.5rem" }}>APY</th>
<th style={{ textAlign: "right", padding: "0.5rem" }}>Uptime</th>
<th style={{ textAlign: "right", padding: "0.5rem" }}>Fee</th>
{readOnly ? null : <th />}
</tr>
</thead>
<tbody>
{slice.map((v) => {
const isSelected = v.nodeID === selectedNodeID
const atCapacity = v.delegationCapacityNLux === 0n
const blocked = v.jailed
return (
<tr
key={v.nodeID}
data-jailed={v.jailed || undefined}
style={{
borderTop: "1px solid rgba(127,127,127,0.2)",
background: isSelected
? "rgba(0,128,255,0.08)"
: undefined,
}}
>
<td style={{ padding: "0.5rem" }}>
<div style={{ fontWeight: 500 }}>
{v.name ?? shortNodeID(v.nodeID)}
</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>
{shortNodeID(v.nodeID)}
{v.jailed ? (
<span
style={{
marginLeft: 8,
padding: "1px 6px",
borderRadius: 4,
background: "#c00",
color: "#fff",
fontSize: 10,
}}
>
JAILED
</span>
) : null}
{atCapacity && !v.jailed ? (
<span
style={{
marginLeft: 8,
padding: "1px 6px",
borderRadius: 4,
background: "#999",
color: "#fff",
fontSize: 10,
}}
>
AT CAPACITY
</span>
) : null}
</div>
</td>
<td style={{ textAlign: "right", padding: "0.5rem" }}>
{formatLux(v.stakeAmountNLux)}
</td>
<td style={{ textAlign: "right", padding: "0.5rem" }}>
{pctFormatter.format(v.apy)}%
</td>
<td style={{ textAlign: "right", padding: "0.5rem" }}>
{pctFormatter.format(v.uptime * 100)}%
</td>
<td style={{ textAlign: "right", padding: "0.5rem" }}>
{pctFormatter.format(v.delegationFeeBps / 100)}%
</td>
{readOnly ? null : (
<td style={{ textAlign: "right", padding: "0.5rem" }}>
<button
type="button"
disabled={blocked || atCapacity}
onClick={() => onSelect?.(v)}
>
{isSelected ? "Selected" : "Delegate"}
</button>
</td>
)}
</tr>
)
})}
{!loading && filtered.length === 0 ? (
<tr>
<td
colSpan={readOnly ? 5 : 6}
style={{ padding: "1rem", textAlign: "center", opacity: 0.7 }}
>
No validators match.
</td>
</tr>
) : null}
</tbody>
</table>
{pageCount > 1 ? (
<nav
aria-label="Validator pagination"
style={{
marginTop: "0.75rem",
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
>
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={safePage === 0}
>
</button>
<span aria-live="polite">
Page {safePage + 1} of {pageCount}
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
disabled={safePage >= pageCount - 1}
>
</button>
</nav>
) : null}
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Stake screen routes.
*
* Exports a `<StakeRoutes />` element that Foundation mounts under
* `<Route path="/stake/*" element={<StakeRoutes />} />`.
*
* Routes:
* /stake → Stake landing
* /stake/validators → ValidatorList (full screen)
* /stake/:nodeID → StakeForm pre-selecting nodeID
*/
import { Link, Route, Routes, useNavigate } from "react-router-dom"
import { Stake } from "./Stake"
import { StakeForm } from "./StakeForm"
import { ValidatorList } from "./ValidatorList"
import { type Validator } from "../../store/stake"
import { useValidators } from "./useValidators"
function ValidatorsPage() {
useValidators()
const navigate = useNavigate()
const onSelect = (v: Validator) =>
navigate(`/stake/${encodeURIComponent(v.nodeID)}`)
return (
<section style={{ padding: "1rem", display: "grid", gap: "1rem" }}>
<header>
<Link to="/stake"> Back</Link>
<h1 style={{ margin: "0.5rem 0" }}>Validators</h1>
</header>
<ValidatorList onSelect={onSelect} />
</section>
)
}
export function StakeRoutes() {
return (
<Routes>
<Route index element={<Stake />} />
<Route path="validators" element={<ValidatorsPage />} />
<Route path=":nodeID" element={<StakeForm />} />
</Routes>
)
}
export default StakeRoutes
export { Stake, StakeForm, ValidatorList }
export { useValidators } from "./useValidators"
export { useStake } from "./useStake"
+212
View File
@@ -0,0 +1,212 @@
/**
* useStake — submits AddDelegator (and AddValidator) transactions on the
* Lux P-Chain.
*
* Trust boundaries:
* - Tx construction happens via the Foundation-supplied signer
* (window.__luxWallet.signPChainTx). This screen never touches private
* keys. We pass an unsigned-tx descriptor; the signer encodes + signs.
* - Broadcast is delegated to Foundation's broadcast helper which talks
* to the canonical P-Chain RPC. Defends against the screen sending
* to a wrong endpoint via a poisoned brand.json.
* - Amount is BigInt nLUX (1 LUX = 1e9 nLUX). No floats. No string
* concatenation. Min/max enforced before submission.
*
* The unsigned-tx descriptor uses the @luxfi/wallet canonical schema so
* Foundation's signer can route it to either the EVM Coreth path (if the
* user holds C-Chain LUX and we need to import via X-Chain) or the
* native P-Chain path. Both terminate in the same AddDelegatorTx output.
*/
import { useCallback, useState } from "react"
import { getAccount } from "../_shared/account"
import { loadRuntimeConfig } from "../_shared/runtime"
import {
PCHAIN_MAX_STAKE_DURATION_SECONDS,
PCHAIN_MIN_DELEGATOR_STAKE_NLUX,
PCHAIN_MIN_STAKE_DURATION_SECONDS,
PCHAIN_MIN_VALIDATOR_STAKE_NLUX,
stakeStore,
} from "../../store/stake"
export type StakeRole = "delegator" | "validator"
export interface StakeInput {
role: StakeRole
nodeID: string
/** Amount in nLUX. */
amountNLux: bigint
/** Stake duration from now, in seconds. Must obey min/max. */
durationSeconds: number
/** Reward address (P-Chain bech32). Defaults to the user's pchain. */
rewardAddress?: string
/** Validator-only: delegation fee bps (used when role === "validator"). */
delegationFeeBps?: number
}
export interface StakeResult {
txID: string
startTime: number
endTime: number
}
const NODE_ID_RE = /^NodeID-[1-9A-HJ-NP-Za-km-z]{32,50}$/
/** P-Chain bech32 address: "P-{hrp}1{34+chars}". */
const PCHAIN_ADDR_RE = /^P-[a-z]{1,16}1[02-9ac-hj-np-z]{38,}$/
function validate(input: StakeInput, fromAddress: string): string | null {
if (!NODE_ID_RE.test(input.nodeID)) return "Invalid validator node ID"
if (!PCHAIN_ADDR_RE.test(fromAddress)) {
return "Account has no P-Chain address; switch chain in settings"
}
if (input.rewardAddress && !PCHAIN_ADDR_RE.test(input.rewardAddress)) {
return "Reward address must be a P-Chain address"
}
if (input.amountNLux <= 0n) return "Amount must be positive"
const min =
input.role === "delegator"
? PCHAIN_MIN_DELEGATOR_STAKE_NLUX
: PCHAIN_MIN_VALIDATOR_STAKE_NLUX
if (input.amountNLux < min) {
const minLux = Number(min) / 1e9
return `Minimum stake is ${minLux} LUX`
}
if (input.durationSeconds < PCHAIN_MIN_STAKE_DURATION_SECONDS) {
return "Lock period must be at least 14 days"
}
if (input.durationSeconds > PCHAIN_MAX_STAKE_DURATION_SECONDS) {
return "Lock period cannot exceed 365 days"
}
if (input.role === "validator") {
const fee = input.delegationFeeBps ?? 0
if (!Number.isInteger(fee) || fee < 0 || fee > 10000) {
return "Delegation fee must be between 0 and 100%"
}
}
return null
}
interface UnsignedAddDelegatorTx {
type: "platform.addDelegator"
nodeID: string
amountNLux: string // bigint serialised
startTime: number
endTime: number
rewardAddress: string
fromAddress: string
}
interface UnsignedAddValidatorTx {
type: "platform.addValidator"
nodeID: string
amountNLux: string
startTime: number
endTime: number
rewardAddress: string
fromAddress: string
delegationFeeBps: number
}
type UnsignedTx = UnsignedAddDelegatorTx | UnsignedAddValidatorTx
function serialise(input: StakeInput, fromAddress: string): UnsignedTx {
// Start a few seconds in the future so the network accepts the tx after
// propagation latency. P-Chain enforces start > now at acceptance time.
const now = Math.floor(Date.now() / 1000)
const startTime = now + 60
const endTime = startTime + input.durationSeconds
const rewardAddress = input.rewardAddress ?? fromAddress
const amountNLux = input.amountNLux.toString()
if (input.role === "validator") {
return {
type: "platform.addValidator",
nodeID: input.nodeID,
amountNLux,
startTime,
endTime,
rewardAddress,
fromAddress,
delegationFeeBps: input.delegationFeeBps ?? 200,
}
}
return {
type: "platform.addDelegator",
nodeID: input.nodeID,
amountNLux,
startTime,
endTime,
rewardAddress,
fromAddress,
}
}
export function useStake() {
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<StakeResult | null>(null)
const submit = useCallback(async (input: StakeInput): Promise<StakeResult> => {
setError(null)
setResult(null)
const account = getAccount()
if (!account) {
const msg = "Wallet locked — unlock to stake"
setError(msg)
throw new Error(msg)
}
const validationErr = validate(input, account.pchain)
if (validationErr) {
setError(validationErr)
throw new Error(validationErr)
}
const signer = window.__luxWallet?.signPChainTx
const broadcaster = window.__luxWallet?.broadcastPChainTx
if (!signer || !broadcaster) {
const msg = "Wallet signer unavailable"
setError(msg)
throw new Error(msg)
}
setSubmitting(true)
try {
// Touch runtime config so brand.json is hot — also ensures we abort
// submission if config never loaded (offline state).
await loadRuntimeConfig()
const unsigned = serialise(input, account.pchain)
const unsignedBytes = new TextEncoder().encode(JSON.stringify(unsigned))
const signed = await signer(unsignedBytes)
const broadcastResult = await broadcaster(signed)
const txID = broadcastResult.txId
const out: StakeResult = {
txID,
startTime: unsigned.startTime,
endTime: unsigned.endTime,
}
setResult(out)
// Optimistic store update so the active stakes list reflects
// immediately. A subsequent fetch will reconcile from chain.
stakeStore.set({
stakes: [
...stakeStore.get().stakes,
{
txID,
nodeID: input.nodeID,
amountNLux: input.amountNLux,
pendingRewardNLux: 0n,
startTime: unsigned.startTime,
endTime: unsigned.endTime,
status: "pending",
},
],
})
return out
} catch (err) {
const msg = err instanceof Error ? err.message : "stake submission failed"
setError(msg)
throw err
} finally {
setSubmitting(false)
}
}, [])
return { submit, submitting, error, result }
}
+241
View File
@@ -0,0 +1,241 @@
/**
* useValidators — fetches the current P-Chain validator set.
*
* Source order:
* 1. Gateway: https://${brand.gatewayDomain}/v1/p-chain/validators
* (authenticated, server-derived APY/uptime/labels — preferred path)
* 2. P-Chain RPC fallback: platform.getCurrentValidators
* (raw on-chain data; APY/uptime computed client-side from rewards
* and connectivity reports)
*
* Validator nodeID format is enforced before commit to the store. This
* defends against gateway response tampering trying to inject HTML or
* malformed identifiers into the table.
*
* Refresh: poll every 30s while screen is mounted; on tab visibility
* change resume polling (avoid burning rate-limit while hidden).
*/
import { useEffect, useRef } from "react"
import { stakeStore, type Validator } from "../../store/stake"
import { loadRuntimeConfig, pchainRpc } from "../_shared/runtime"
const NODE_ID_RE = /^NodeID-[1-9A-HJ-NP-Za-km-z]{32,50}$/
const POLL_MS = 30_000
interface RawGatewayValidator {
nodeID?: string
name?: string
stakeAmount?: string
apy?: number
uptime?: number
startTime?: number | string
endTime?: number | string
delegationFee?: number | string
delegationCapacity?: string
jailed?: boolean
}
interface RawRpcValidator {
nodeID?: string
stakeAmount?: string
weight?: string
uptime?: string
startTime?: string
endTime?: string
delegationFee?: string
potentialReward?: string
connected?: boolean
}
function safeBigInt(value: unknown, fallback = 0n): bigint {
if (typeof value === "bigint") return value
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
return BigInt(Math.trunc(value))
}
if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value)
return fallback
}
function safeNumber(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string") {
const n = Number(value)
if (Number.isFinite(n)) return n
}
return fallback
}
function normaliseGateway(rows: RawGatewayValidator[]): Validator[] {
const out: Validator[] = []
for (const r of rows) {
if (typeof r.nodeID !== "string" || !NODE_ID_RE.test(r.nodeID)) continue
out.push({
nodeID: r.nodeID,
name: typeof r.name === "string" ? r.name.slice(0, 64) : undefined,
stakeAmountNLux: safeBigInt(r.stakeAmount),
apy: Math.max(0, Math.min(100, safeNumber(r.apy))),
uptime: Math.max(0, Math.min(1, safeNumber(r.uptime))),
startTime: safeNumber(r.startTime),
endTime: safeNumber(r.endTime),
delegationFeeBps: Math.round(
Math.max(0, Math.min(10000, safeNumber(r.delegationFee) * 100)),
),
delegationCapacityNLux: safeBigInt(r.delegationCapacity),
jailed: r.jailed === true,
})
}
return out
}
function normaliseRpc(rows: RawRpcValidator[]): Validator[] {
const out: Validator[] = []
for (const r of rows) {
if (typeof r.nodeID !== "string" || !NODE_ID_RE.test(r.nodeID)) continue
const stake = safeBigInt(r.stakeAmount ?? r.weight)
const startTime = safeNumber(r.startTime)
const endTime = safeNumber(r.endTime)
const reward = safeBigInt(r.potentialReward)
// Approximate APY from reward over the validation period.
const periodSeconds = Math.max(1, endTime - startTime)
const periodYears = periodSeconds / (365 * 24 * 60 * 60)
const apy =
stake > 0n && periodYears > 0
? Math.min(
100,
(Number(reward) / Number(stake) / periodYears) * 100,
)
: 0
const uptime = Math.max(0, Math.min(1, safeNumber(r.uptime)))
out.push({
nodeID: r.nodeID,
stakeAmountNLux: stake,
apy,
uptime,
startTime,
endTime,
delegationFeeBps: Math.round(safeNumber(r.delegationFee) * 100),
delegationCapacityNLux: 0n,
jailed: r.connected === false,
})
}
return out
}
async function fetchFromGateway(
gatewayDomain: string,
signal: AbortSignal,
): Promise<Validator[]> {
const url = `https://${gatewayDomain}/v1/p-chain/validators`
const res = await fetch(url, {
signal,
headers: { accept: "application/json" },
})
if (!res.ok) throw new Error(`gateway ${res.status}`)
const json = (await res.json()) as { validators?: RawGatewayValidator[] }
if (!Array.isArray(json.validators)) {
throw new Error("gateway: invalid response shape")
}
return normaliseGateway(json.validators)
}
async function fetchFromRpc(
rpcUrl: string,
signal: AbortSignal,
): Promise<Validator[]> {
const res = await fetch(rpcUrl, {
method: "POST",
signal,
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "platform.getCurrentValidators",
params: { subnetID: null, nodeIDs: [] },
}),
})
if (!res.ok) throw new Error(`rpc ${res.status}`)
const json = (await res.json()) as {
error?: { message: string }
result?: { validators?: RawRpcValidator[] }
}
if (json.error) throw new Error(`rpc: ${json.error.message}`)
const rows = json.result?.validators
if (!Array.isArray(rows)) throw new Error("rpc: invalid response shape")
return normaliseRpc(rows)
}
export function useValidators(): void {
const aborter = useRef<AbortController | null>(null)
const timer = useRef<number | null>(null)
useEffect(() => {
let stopped = false
const tick = async () => {
if (stopped) return
stakeStore.set({ validatorsLoading: true, validatorsError: null })
const ac = new AbortController()
aborter.current = ac
try {
const cfg = await loadRuntimeConfig()
const gateway = cfg.brand.gatewayDomain
let validators: Validator[] = []
let lastErr: unknown = null
if (gateway) {
try {
validators = await fetchFromGateway(gateway, ac.signal)
} catch (err) {
lastErr = err
}
}
if (validators.length === 0) {
try {
validators = await fetchFromRpc(pchainRpc(cfg), ac.signal)
} catch (err) {
lastErr = err
}
}
if (stopped) return
if (validators.length === 0 && lastErr) {
stakeStore.set({
validators: [],
validatorsLoading: false,
validatorsError:
lastErr instanceof Error ? lastErr.message : "unknown error",
})
return
}
stakeStore.set({
validators,
validatorsLoading: false,
validatorsError: null,
})
} catch (err) {
if (stopped || ac.signal.aborted) return
stakeStore.set({
validatorsLoading: false,
validatorsError:
err instanceof Error ? err.message : "validator fetch failed",
})
}
}
tick()
timer.current = window.setInterval(() => {
if (!document.hidden) tick()
}, POLL_MS)
const visibility = () => {
if (!document.hidden) tick()
}
document.addEventListener("visibilitychange", visibility)
return () => {
stopped = true
if (timer.current) window.clearInterval(timer.current)
aborter.current?.abort()
document.removeEventListener("visibilitychange", visibility)
}
}, [])
}
+118
View File
@@ -0,0 +1,118 @@
/**
* dApps store — WalletConnect v2 sessions, pending pairing, pending requests.
*
* State is the source of truth for "what dApps does the user have connected".
* The useWalletConnect hook listens to sign-client events and dispatches into
* this store. UI components read from the store via useDAppsStore.
*
* No persistence here — sign-client handles its own storage (IndexedDB).
*/
import { useSyncExternalStore } from "react"
/** dApp metadata as supplied in the proposal/session — never trusted blindly. */
export interface DAppMetadata {
name: string
description: string
url: string
icons: string[]
}
/** Active WalletConnect session. */
export interface DAppSession {
/** sign-client topic, primary key. */
topic: string
peer: DAppMetadata
/** Approved chains (CAIP-2 strings, e.g. "eip155:96369"). */
chains: string[]
/** Approved methods (e.g. "eth_sendTransaction"). */
methods: string[]
/** Approved events (e.g. "chainChanged"). */
events: string[]
/** Session expiry (unix seconds). */
expiry: number
/** Unix ms when the session was approved by the user. */
acquiredAt: number
}
/** Pending pairing — pre-session_proposal state. */
export interface PendingPairing {
topic: string
uri: string
/** Unix ms when the pairing was initiated. */
startedAt: number
}
interface DAppsState {
sessions: DAppSession[]
pendingPairings: PendingPairing[]
/** Most recent error from sign-client. Null when clean. */
error: string | null
/** True while sign-client is initialising. */
initializing: boolean
}
const initial: DAppsState = {
sessions: [],
pendingPairings: [],
error: null,
initializing: false,
}
let state: DAppsState = initial
const listeners = new Set<() => void>()
function emit() {
for (const l of listeners) l()
}
export const dappsStore = {
get(): DAppsState {
return state
},
set(patch: Partial<DAppsState>) {
state = { ...state, ...patch }
emit()
},
upsertSession(session: DAppSession) {
const without = state.sessions.filter((s) => s.topic !== session.topic)
state = { ...state, sessions: [...without, session] }
emit()
},
removeSession(topic: string) {
state = {
...state,
sessions: state.sessions.filter((s) => s.topic !== topic),
}
emit()
},
addPendingPairing(p: PendingPairing) {
state = { ...state, pendingPairings: [...state.pendingPairings, p] }
emit()
},
removePendingPairing(topic: string) {
state = {
...state,
pendingPairings: state.pendingPairings.filter((p) => p.topic !== topic),
}
emit()
},
subscribe(cb: () => void): () => void {
listeners.add(cb)
return () => {
listeners.delete(cb)
}
},
reset() {
state = initial
emit()
},
}
export function useDAppsStore<T>(selector: (s: DAppsState) => T): T {
return useSyncExternalStore(
dappsStore.subscribe,
() => selector(state),
() => selector(state),
)
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Stake store — validators, active stakes, pending rewards.
*
* Lightweight subscription store (no redux dependency). Foundation may swap
* the implementation for redux/zustand later — the public surface
* (useStakeStore, stakeStore.set/get) is stable.
*
* State is in-memory only. Validator lists are re-fetched on screen mount;
* stake submissions update the store optimistically and reconcile from
* P-Chain on the next poll.
*/
import { useSyncExternalStore } from "react"
/** Validator record from getCurrentValidators / gateway. */
export interface Validator {
/** "NodeID-…" — primary key */
nodeID: string
/** Optional human label resolved from validator metadata. */
name?: string
/** Total stake delegated to this validator, in nLUX (1 LUX = 1e9 nLUX). */
stakeAmountNLux: bigint
/** Annualised reward percentage (server-computed; 0100). */
apy: number
/** Uptime as a fraction 01. */
uptime: number
/** Validation start time (unix seconds). */
startTime: number
/** Validation end time (unix seconds). */
endTime: number
/** Delegation fee charged by the validator, basis points (e.g. 200 = 2%). */
delegationFeeBps: number
/** Capacity remaining for new delegations, in nLUX. 0 = at capacity. */
delegationCapacityNLux: bigint
/** True if validator is jailed/slashed. UI must block new delegations. */
jailed: boolean
}
/** Active delegation owned by the user. */
export interface ActiveStake {
/** Originating tx ID (hex). */
txID: string
nodeID: string
/** Staked amount in nLUX. */
amountNLux: bigint
/** Pending reward in nLUX (unclaimed). */
pendingRewardNLux: bigint
startTime: number
endTime: number
/** "Pending" while subnet has not yet activated; "Active" once running. */
status: "pending" | "active" | "completed"
}
interface StakeState {
validators: Validator[]
validatorsLoading: boolean
validatorsError: string | null
stakes: ActiveStake[]
stakesLoading: boolean
stakesError: string | null
/** Total claimable rewards across all stakes, in nLUX. */
totalClaimableNLux: bigint
}
const initial: StakeState = {
validators: [],
validatorsLoading: false,
validatorsError: null,
stakes: [],
stakesLoading: false,
stakesError: null,
totalClaimableNLux: 0n,
}
let state: StakeState = initial
const listeners = new Set<() => void>()
function emit() {
for (const l of listeners) l()
}
export const stakeStore = {
get(): StakeState {
return state
},
set(patch: Partial<StakeState>) {
state = { ...state, ...patch }
if (patch.stakes) {
// Re-derive total claimable from stakes if they changed.
state.totalClaimableNLux = state.stakes.reduce(
(sum, s) => sum + s.pendingRewardNLux,
0n,
)
}
emit()
},
subscribe(cb: () => void): () => void {
listeners.add(cb)
return () => {
listeners.delete(cb)
}
},
reset() {
state = initial
emit()
},
}
export function useStakeStore<T>(selector: (s: StakeState) => T): T {
return useSyncExternalStore(
stakeStore.subscribe,
() => selector(state),
() => selector(state),
)
}
/** P-Chain minimum stake parameters. */
export const PCHAIN_MIN_STAKE_DURATION_SECONDS = 14 * 24 * 60 * 60 // 2 weeks
export const PCHAIN_MAX_STAKE_DURATION_SECONDS = 365 * 24 * 60 * 60 // 1 year
export const PCHAIN_MIN_DELEGATOR_STAKE_NLUX = 25n * 10n ** 9n // 25 LUX
export const PCHAIN_MIN_VALIDATOR_STAKE_NLUX = 2000n * 10n ** 9n // 2,000 LUX
+11392 -8425
View File
File diff suppressed because it is too large Load Diff