mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +00:00
merge: wallet feat/stake-dapps slice
# Conflicts: # apps/web/package.json # apps/web/src/screens/dapps/index.tsx # apps/web/src/screens/stake/index.tsx # pnpm-lock.yaml
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
<<<<<<< HEAD
|
||||
"@hanzo/gui": "^7.0.0",
|
||||
"@luxfi/wallet-analytics": "workspace:^",
|
||||
"@luxfi/wallet-brand": "workspace:^",
|
||||
@@ -28,6 +29,13 @@
|
||||
"viem": "^2.30.0",
|
||||
"wagmi": "^2.15.0",
|
||||
"zustand": "^5.0.0"
|
||||
=======
|
||||
"@walletconnect/sign-client": "^2.21.0",
|
||||
"@walletconnect/utils": "^2.21.0",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-router-dom": "^7.5.0"
|
||||
>>>>>>> origin/feat/stake-dapps
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,31 @@
|
||||
/**
|
||||
* DApps screen — Foundation placeholder.
|
||||
* dApps screen routes.
|
||||
*
|
||||
* Owned by Stake-DApps Blue. Includes the WalletConnect / dapp browser.
|
||||
* 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 />} />`.
|
||||
*/
|
||||
export default function DApps(): React.JSX.Element {
|
||||
|
||||
import { Route, Routes } from "react-router-dom"
|
||||
import { ActiveSessions } from "./ActiveSessions"
|
||||
import { Connect } from "./Connect"
|
||||
import { DApps } from "./DApps"
|
||||
|
||||
export function DAppsRoutes() {
|
||||
return (
|
||||
<section>
|
||||
<h1 style={{ fontSize: 24, marginBottom: 8 }}>DApps</h1>
|
||||
<p style={{ color: "var(--neutral2, #888)" }}>Stake-DApps Blue: replace this file.</p>
|
||||
</section>
|
||||
<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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,49 @@
|
||||
/**
|
||||
* Stake screen — Foundation placeholder.
|
||||
* Stake screen routes.
|
||||
*
|
||||
* Owned by Stake-DApps Blue. Replace with SCREENS.md §4 Staking.
|
||||
* 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
|
||||
*/
|
||||
export default function Stake(): React.JSX.Element {
|
||||
|
||||
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>
|
||||
<h1 style={{ fontSize: 24, marginBottom: 8 }}>Stake</h1>
|
||||
<p style={{ color: "var(--neutral2, #888)" }}>Stake-DApps Blue: replace this file.</p>
|
||||
<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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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; 0–100). */
|
||||
apy: number
|
||||
/** Uptime as a fraction 0–1. */
|
||||
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
|
||||
Generated
+446
-11413
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user