feat(web): settings + signing slice (7/7 — wallet UX complete)

Settings sub-router (`/settings/*`) with eight screens:

  Settings.tsx — top-level menu
  Networks.tsx — per-chain RPC override (HTTPS-only, persisted)
  Security.tsx — PIN change, biometric toggle, auto-lock 1/5/15/60/never
  Backup.tsx   — PIN re-auth → reveal mnemonic → 30s auto-redact + ack
  Language.tsx — 10 locales, en default
  Theme.tsx    — system / dark / light
  Currency.tsx — USD / EUR / JPY / GBP / CHF / CAD
  About.tsx    — VERSION + /.upstream-sha + GPL-3 + brand legal links

Signing modal — global tx confirmation triggered by Send / Swap /
Bridge / dApps before broadcast:

  SigningModal.tsx   — backdrop + ESC + reject-on-close
  TxSummary.tsx      — from / to / amount / gas / chain + ContractDecode
                       (local ABI + opt-in 4byte fallback)
  RiskIndicator.tsx  — green / yellow / red heuristics
                       (known contract → green, MAX_UINT approve → yellow,
                        unverified contract → red)
  useSigningModal.ts — hook + imperative API; promise-resolving queue,
                       single in-flight request enforced

State (`store/settings.ts`):
  { networks: { [chainId]: { rpcOverride } },
    security: { biometric, autoLockMin },
    locale, theme, currency }
  Persisted via zustand `persist` middleware, partialize boundary
  excludes nothing (pure UI prefs, no secrets).

State (`store/signing.ts`):
  Single-slot queue keyed by `pending`. `open(tx)` returns
  `Promise<{approved, reason?}>`. `beforeunload` rejects pending so
  caller awaits unwind cleanly.

Wired at app root: `App.tsx` mounts `<SigningModal />` lazily inside
`<Suspense>` so the modal is reachable from any slice but stays out
of the auth bootstrap bundle.

Constraints honoured:
  - ▼ brand defaults via `lib/brand.ts` + `getBootnodeRpcUrl()`,
    no empty-string URLs, white-label-ready.
  - Mnemonic reveal: PIN re-auth required, 30s auto-redact timer,
    overwrites on unmount, never logged or auto-clipboarded.
  - Risk heuristics never block; user always confirms.
  - Currency conversions through `https://${brand.gatewayDomain}/v1/prices`
    with 30s in-memory cache, no calldata leaks.

Build: 23.0 KB settings chunk (6.2 KB gzipped), 13.9 KB SigningModal
chunk (4.8 KB gzipped). Total dist 7.6 MB.
This commit is contained in:
Hanzo AI
2026-04-30 17:52:26 -07:00
parent 61332c02de
commit f028efd393
20 changed files with 2958 additions and 8 deletions
+9 -1
View File
@@ -10,7 +10,7 @@
* are populated. The config is memoized at module init via a lazy ref so
* StrictMode's double render does not re-instantiate WalletConnect.
*/
import { useMemo } from "react"
import { lazy, Suspense, useMemo } from "react"
import { RouterProvider } from "react-router-dom"
import { QueryClientProvider } from "@tanstack/react-query"
import { WagmiProvider } from "wagmi"
@@ -19,6 +19,11 @@ import { buildWagmiConfig } from "./config/wagmi"
import { queryClient } from "./config/queryClient"
import { router } from "./router"
// Lazy-load the signing modal so the auth/portfolio bootstrap path doesn't
// pull abi.ts + prices.ts into the initial bundle. The modal mounts at
// app root and any slice can trigger it via `useSigningModal().open(tx)`.
const SigningModal = lazy(() => import("./screens/signing/SigningModal"))
export default function App(): React.JSX.Element {
// Build the wagmi config once. `useMemo` keeps StrictMode's double render
// from creating two configs; module-level instantiation would force
@@ -30,6 +35,9 @@ export default function App(): React.JSX.Element {
<QueryClientProvider client={queryClient}>
<WagmiProvider config={wagmiConfig}>
<RouterProvider router={router} />
<Suspense fallback={null}>
<SigningModal />
</Suspense>
</WagmiProvider>
</QueryClientProvider>
</GuiProvider>
+245
View File
@@ -0,0 +1,245 @@
/**
* ABI decoder — local table for the calls we care about, with optional
* 4byte directory fallback (gated behind an explicit user click).
*
* Local table covers the canonical signatures that appear in 99% of
* wallet flows: ERC-20 approve/transfer/transferFrom, swap routing
* functions, multicall, permit, etc. Everything else falls back to the
* 4byte directory IF the user opts in.
*
* Why opt-in for the network call: the calldata is the user's intent,
* but the function selector ("0x12345678") is also a fingerprint of the
* contract being called. Sending it to a third-party directory before
* the user has explicitly asked is a privacy regression.
*
* No `fetch` happens at module load; selectors only resolve when
* `decodeCalldata` is invoked. Use `decodeLocal` for synchronous local-only
* decode (returns null when the selector isn't in the table).
*/
export interface DecodedArg {
name: string
type: string
/** String representation — addresses are lower-cased, uints decimal. */
value: string
}
export interface DecodedCall {
/** Bare function name. */
name: string
/** Canonical signature, e.g. `transfer(address,uint256)`. */
signature: string
/** Decoded arguments in declaration order. */
args: DecodedArg[]
/**
* `true` if resolved from the local table, `false` if 4byte, `null`
* if the selector was unknown and we returned a placeholder. Drives
* the "via 4byte directory" / "unknown" badge in TxSummary.
*/
local: boolean | null
}
/**
* Local signature table. Each entry maps a 4-byte selector (lower-case
* hex, no `0x`) to the decoded shape. Args' `value` is filled in by
* the decoder.
*/
const LOCAL_TABLE: Record<
string,
{ name: string; signature: string; argTypes: { name: string; type: string }[] }
> = {
// ERC-20
"095ea7b3": {
name: "approve",
signature: "approve(address,uint256)",
argTypes: [
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
],
},
a9059cbb: {
name: "transfer",
signature: "transfer(address,uint256)",
argTypes: [
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
],
},
"23b872dd": {
name: "transferFrom",
signature: "transferFrom(address,address,uint256)",
argTypes: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
],
},
// Uniswap-V2 router
"38ed1739": {
name: "swapExactTokensForTokens",
signature:
"swapExactTokensForTokens(uint256,uint256,address[],address,uint256)",
argTypes: [
{ name: "amountIn", type: "uint256" },
{ name: "amountOutMin", type: "uint256" },
{ name: "path", type: "address[]" },
{ name: "to", type: "address" },
{ name: "deadline", type: "uint256" },
],
},
"7ff36ab5": {
name: "swapExactETHForTokens",
signature: "swapExactETHForTokens(uint256,address[],address,uint256)",
argTypes: [
{ name: "amountOutMin", type: "uint256" },
{ name: "path", type: "address[]" },
{ name: "to", type: "address" },
{ name: "deadline", type: "uint256" },
],
},
"18cbafe5": {
name: "swapExactTokensForETH",
signature: "swapExactTokensForETH(uint256,uint256,address[],address,uint256)",
argTypes: [
{ name: "amountIn", type: "uint256" },
{ name: "amountOutMin", type: "uint256" },
{ name: "path", type: "address[]" },
{ name: "to", type: "address" },
{ name: "deadline", type: "uint256" },
],
},
// Permit2
"2b67b570": {
name: "permit",
signature:
"permit(address,((address,uint160,uint48,uint48),address,uint256),bytes)",
argTypes: [
{ name: "owner", type: "address" },
{ name: "permitSingle", type: "tuple" },
{ name: "signature", type: "bytes" },
],
},
// Multicall
ac9650d8: {
name: "multicall",
signature: "multicall(bytes[])",
argTypes: [{ name: "data", type: "bytes[]" }],
},
}
/** Strip 0x prefix and lower-case the rest. */
function strip0x(s: string): string {
return (s.startsWith("0x") || s.startsWith("0X") ? s.slice(2) : s).toLowerCase()
}
/**
* Decode a single ABI parameter chunk (32 bytes hex). Only handles the
* static types we care about + the bytes/array length headers. Dynamic
* types fall through to a hex passthrough — good enough for display.
*/
function decodeParam(type: string, hex: string): string {
const word = hex.slice(0, 64)
if (type === "address") {
// last 20 bytes of the word.
return "0x" + word.slice(24)
}
if (type.startsWith("uint") || type.startsWith("int")) {
if (!word) return "0"
return BigInt("0x" + word).toString()
}
if (type === "bool") {
return BigInt("0x" + word) === 0n ? "false" : "true"
}
if (type === "address[]" || type.endsWith("[]") || type === "bytes" || type === "tuple") {
// Display the offset pointer as-is; full dynamic decode is out of
// scope for the modal — TxSummary shows the function name + sig
// and the user can inspect on a block explorer.
return "0x" + word
}
return "0x" + word
}
/**
* Synchronous local decode. Returns null if the selector is not in
* the table. Call sites display the raw selector + offer the
* "Look up signature" button to invoke `decodeCalldata({network:true})`.
*/
export function decodeLocal(data: string): DecodedCall | null {
const stripped = strip0x(data)
if (stripped.length < 8) return null
const selector = stripped.slice(0, 8)
const entry = LOCAL_TABLE[selector]
if (!entry) return null
const body = stripped.slice(8)
const args: DecodedArg[] = entry.argTypes.map((a, i) => {
const word = body.slice(i * 64, (i + 1) * 64)
return { name: a.name, type: a.type, value: decodeParam(a.type, word) }
})
return { name: entry.name, signature: entry.signature, args, local: true }
}
/**
* Async decode. Tries the local table first; on miss and `network:true`,
* queries the 4byte directory. We fetch ONLY the selector's known text
* signature (no calldata), so the privacy footprint is minimal.
*
* 4byte returns multiple matches sometimes (selector collisions). We
* pick the one with the lowest id (the earliest registered) since that
* tends to be the canonical signature.
*/
export async function decodeCalldata(
data: string,
opts: { network?: boolean } = {},
): Promise<DecodedCall | null> {
const local = decodeLocal(data)
if (local) return local
if (!opts.network) return null
const stripped = strip0x(data)
if (stripped.length < 8) return null
const selector = stripped.slice(0, 8)
try {
const url = `https://www.4byte.directory/api/v1/signatures/?hex_signature=0x${selector}`
const res = await fetch(url, { headers: { accept: "application/json" } })
if (!res.ok) return unknownPlaceholder(selector)
const json = (await res.json()) as {
results: { id: number; text_signature: string }[]
}
if (!json.results?.length) return unknownPlaceholder(selector)
const match = json.results.reduce((a, b) => (a.id < b.id ? a : b))
return parseSignature(selector, match.text_signature, stripped.slice(8))
} catch {
return unknownPlaceholder(selector)
}
}
function unknownPlaceholder(selector: string): DecodedCall {
return {
name: `unknown_${selector}`,
signature: `0x${selector}`,
args: [],
local: null,
}
}
/** Parse `name(type1,type2,...)` and decode each chunk best-effort. */
function parseSignature(
selector: string,
sig: string,
body: string,
): DecodedCall {
const m = sig.match(/^([a-zA-Z_$][\w$]*)\(([^)]*)\)$/)
if (!m) return unknownPlaceholder(selector)
const name = m[1]!
const types = m[2]!
.split(",")
.map((t) => t.trim())
.filter(Boolean)
const args: DecodedArg[] = types.map((type, i) => {
const word = body.slice(i * 64, (i + 1) * 64)
return { name: `arg${i}`, type, value: decodeParam(type, word) }
})
return { name, signature: sig, args, local: false }
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Brand façade for screens.
*
* The canonical brand singleton lives in `@luxfi/wallet-brand`. Screens
* import from this file so future refactors (e.g. context-based brand)
* touch one path. We also expose `chainLabel(id)` here because every
* screen pretty-prints chain ids and a single source keeps the labels
* consistent.
*/
import { brand as runtimeBrand, type BrandConfig } from "@luxfi/wallet-brand"
/** Compatibility shape for stash code that called `getBrand().brand`. */
export interface BrandHandle {
brand: BrandConfig
}
/** Synchronous brand accessor — `brand` is mutated in place by `loadBrandConfig`. */
export function getBrand(): BrandHandle {
return { brand: runtimeBrand }
}
/** Direct named export for code that just wants the singleton. */
export const brand = runtimeBrand
const CHAIN_LABELS: Record<number, string> = {
1: "Ethereum",
137: "Polygon",
8453: "Base",
42161: "Arbitrum One",
43114: "Avalanche",
96369: "Lux",
96368: "Lux Testnet",
96370: "Lux DEX",
200200: "Zoo",
200201: "Zoo Testnet",
36963: "Hanzo",
36964: "Hanzo Testnet",
36911: "Q-Chain",
36910: "Q-Chain Testnet",
494949: "F-Chain",
7071: "F-Chain Testnet",
}
/** Render a chain id as a readable label, or `Chain {id}` if unknown. */
export function chainLabel(id: number): string {
return CHAIN_LABELS[id] ?? `Chain ${id}`
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Price fetcher — single canonical surface used by Send / Swap / Bridge /
* Signing. Routes through the brand gateway so each white-label deployment
* pins its own pricing source.
*
* GET https://${brand.gatewayDomain}/v1/prices?symbols=LUX,ETH&vs=USD
* → { LUX: 2.34, ETH: 3120.55 }
*
* Behaviour:
* - Returns `{}` on any error. Call sites must handle missing keys.
* - In-flight calls are deduped per (symbols, vs) tuple.
* - Results cached for 30s in-memory. Wallets show stale-but-acceptable
* prices; the user's signing modal does NOT block on a fresh fetch.
* - We never ship calldata or addresses to the gateway from this path —
* only ticker symbols. PII-clean.
*
* Note: gateway URL is built lazily so callers don't need to await
* `loadBrandConfig()` themselves; an empty `gatewayDomain` short-circuits
* to `{}` (dev / preview without a gateway).
*/
import { brand } from "@luxfi/wallet-brand"
export type PriceMap = Record<string, number>
interface CacheEntry {
at: number
data: PriceMap
}
const TTL_MS = 30_000
const cache = new Map<string, CacheEntry>()
const inflight = new Map<string, Promise<PriceMap>>()
function cacheKey(symbols: string[], vs: string): string {
return `${vs.toUpperCase()}|${[...symbols].map((s) => s.toUpperCase()).sort().join(",")}`
}
export async function fetchPrices(
symbols: string[],
vs = "USD",
): Promise<PriceMap> {
if (!symbols.length) return {}
const key = cacheKey(symbols, vs)
const now = Date.now()
const cached = cache.get(key)
if (cached && now - cached.at < TTL_MS) return cached.data
const inFlight = inflight.get(key)
if (inFlight) return inFlight
const fetchOnce = (async () => {
if (!brand.gatewayDomain) return {}
try {
const url =
`https://${brand.gatewayDomain}/v1/prices` +
`?symbols=${encodeURIComponent(symbols.join(","))}` +
`&vs=${encodeURIComponent(vs)}`
const res = await fetch(url, { headers: { accept: "application/json" } })
if (!res.ok) return {}
const json = (await res.json()) as PriceMap
// Normalise: upper-case keys, numeric values.
const out: PriceMap = {}
for (const [k, v] of Object.entries(json)) {
const n = Number(v)
if (Number.isFinite(n)) out[k.toUpperCase()] = n
}
cache.set(key, { at: Date.now(), data: out })
return out
} catch {
return {}
} finally {
inflight.delete(key)
}
})()
inflight.set(key, fetchOnce)
return fetchOnce
}
/** Convert a native-units amount to the user's display currency. */
export function convertToCurrency(
nativeAmount: number,
unitPrice: number,
): number {
if (!Number.isFinite(nativeAmount) || !Number.isFinite(unitPrice)) return 0
return nativeAmount * unitPrice
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Version + upstream commit metadata for the About screen.
*
* `VERSION` is the SPA's `package.json` version, embedded by Vite at build
* time via `import.meta.env`. We DO NOT import package.json directly —
* that would force-bundle every `package.json` field into the client.
*
* `loadUpstreamSha()` fetches `/.upstream-sha` (a static file dropped by
* CI containing the source commit hash). Returns `"unknown"` if the file
* is missing in dev / preview.
*
* Mirror of `screens/_shared/version.ts`. Kept here so screens that import
* from `lib/` don't have to reach into the `_shared` neighbourhood.
*/
declare global {
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string
}
}
export const VERSION =
(typeof import.meta !== "undefined" && import.meta.env?.VITE_APP_VERSION) ||
"0.1.0"
let cachedSha: string | null = null
export async function loadUpstreamSha(): Promise<string> {
if (cachedSha !== null) return cachedSha
if (typeof fetch === "undefined") {
cachedSha = "unknown"
return cachedSha
}
try {
const res = await fetch("/.upstream-sha", { cache: "no-cache" })
if (!res.ok) {
cachedSha = "unknown"
return cachedSha
}
const text = (await res.text()).trim()
cachedSha = text.length > 0 ? text : "unknown"
} catch {
cachedSha = "unknown"
}
return cachedSha
}
export function shortSha(sha: string): string {
if (sha === "unknown") return sha
return sha.slice(0, 7)
}
+139
View File
@@ -0,0 +1,139 @@
/**
* About — version, upstream commit, license, legal links.
*
* Renders synchronously (with `unknown` placeholders) and patches in the
* upstream sha once `loadUpstreamSha()` resolves. License is GPL-3.0-or-later
* — link to the canonical FSF page rather than bundling the full text.
*/
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { getBrand } from "../../lib/brand"
import { VERSION, loadUpstreamSha, shortSha } from "../../lib/version"
export default function About() {
const brand = getBrand().brand
const [sha, setSha] = useState<string>("loading")
useEffect(() => {
let mounted = true
loadUpstreamSha().then((s) => {
if (mounted) setSha(s)
})
return () => {
mounted = false
}
}, [])
return (
<main style={page}>
<header style={header}>
<Link to=".." relative="path" style={back}>
Settings
</Link>
<h1 style={title}>About</h1>
</header>
<section style={card}>
<Row label="App" value={brand.walletName} />
<Row label="Version" value={VERSION} mono />
<Row label="Source" value={shortSha(sha)} mono />
<Row label="Domain" value={brand.appDomain} />
</section>
<section style={card}>
<h2 style={h2}>Legal</h2>
<Anchor href="https://www.gnu.org/licenses/gpl-3.0.html" label="License: GPL-3.0-or-later" />
<Anchor href={brand.privacyUrl} label="Privacy Policy" />
<Anchor href={brand.termsUrl} label="Terms of Service" />
</section>
<section style={card}>
<h2 style={h2}>Support</h2>
<Anchor href={brand.helpUrl} label="Documentation" />
<Anchor href={`mailto:${brand.supportEmail}`} label={brand.supportEmail} />
<Anchor href={brand.github} label="Source on GitHub" />
</section>
</main>
)
}
function Row({
label,
value,
mono,
}: {
label: string
value: string
mono?: boolean
}) {
return (
<div style={row}>
<span style={rowLabel}>{label}</span>
<span style={mono ? valueMono : rowValue}>{value}</span>
</div>
)
}
function Anchor({ href, label }: { href: string; label: string }) {
return (
<a href={href} target="_blank" rel="noreferrer noopener" style={anchor}>
{label}
<span aria-hidden style={anchorChev}>
</span>
</a>
)
}
const page: React.CSSProperties = {
minHeight: "100vh",
background: "#000",
color: "#fff",
padding: "24px 16px",
maxWidth: 640,
margin: "0 auto",
}
const header: React.CSSProperties = { marginBottom: 16 }
const back: React.CSSProperties = {
color: "rgba(255,255,255,0.6)",
textDecoration: "none",
fontSize: 14,
}
const title: React.CSSProperties = { fontSize: 28, fontWeight: 600, margin: "8px 0" }
const card: React.CSSProperties = {
border: "1px solid #1a1a1a",
borderRadius: 12,
padding: 4,
marginBottom: 12,
}
const h2: React.CSSProperties = {
fontSize: 12,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: 0.5,
color: "rgba(255,255,255,0.5)",
margin: "8px 12px 4px",
}
const row: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
padding: "12px 14px",
borderBottom: "1px solid #141414",
}
const rowLabel: React.CSSProperties = { color: "rgba(255,255,255,0.6)", fontSize: 13 }
const rowValue: React.CSSProperties = { fontSize: 13 }
const valueMono: React.CSSProperties = {
fontSize: 13,
fontFamily: "ui-monospace, SFMono-Regular, monospace",
}
const anchor: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 14px",
borderBottom: "1px solid #141414",
color: "#fff",
textDecoration: "none",
fontSize: 14,
}
const anchorChev: React.CSSProperties = { color: "rgba(255,255,255,0.4)", fontSize: 12 }
+322
View File
@@ -0,0 +1,322 @@
/**
* Backup — reveal recovery phrase.
*
* Threat model:
* - The mnemonic is the master secret. Any leak is total compromise.
* - PIN re-auth is required on every reveal — even if the wallet is
* already unlocked. Shoulder-surfing an unlocked tab is real.
* - Plaintext is held only in component state, only for 30 seconds, and
* is overwritten + cleared on any unmount, navigation, or timeout.
* - We do not log it. We do not analytics it. We do not auto-copy it.
* The "Copy" button requires an explicit user click; an "I've saved
* it" confirmation must be clicked before navigation away.
* - We never call any URL with the mnemonic in scope.
*
* This file owns the reveal UI; the underlying decrypt happens in
* Foundation Blue's PIN handle (`getPinAuth().unlock(pin)` returns the
* mnemonic on success).
*/
import { useEffect, useRef, useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { getPinAuth, validatePin } from "../../lib/pin"
const REDACT_AFTER_MS = 30_000
export default function Backup() {
const navigate = useNavigate()
const [pin, setPin] = useState("")
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [revealed, setRevealed] = useState<string | null>(null)
const [acknowledged, setAcknowledged] = useState(false)
const [secsLeft, setSecsLeft] = useState(REDACT_AFTER_MS / 1000)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const redactRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Auto-redact + countdown.
useEffect(() => {
if (!revealed) return
setSecsLeft(REDACT_AFTER_MS / 1000)
timerRef.current = setInterval(() => {
setSecsLeft((s) => Math.max(0, s - 1))
}, 1000)
redactRef.current = setTimeout(() => {
// Best-effort overwrite. JS has no secure-erase; this at least
// breaks the v8 string interning chain for fresh allocations.
setRevealed(null)
}, REDACT_AFTER_MS)
return () => {
if (timerRef.current) clearInterval(timerRef.current)
if (redactRef.current) clearTimeout(redactRef.current)
}
}, [revealed])
// Wipe on unmount.
useEffect(() => {
return () => {
setRevealed(null)
setPin("")
}
}, [])
const onReveal = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!validatePin(pin)) {
setError("PIN must be 6 digits")
return
}
setBusy(true)
try {
const m = await getPinAuth().unlock(pin)
if (!m) {
setError("Incorrect PIN")
} else {
setRevealed(m)
setPin("")
setAcknowledged(false)
}
} catch (e) {
setError((e as Error).message ?? "Reveal failed")
} finally {
setBusy(false)
}
}
const onCopy = async () => {
if (!revealed) return
try {
await navigator.clipboard.writeText(revealed)
} catch {
// Clipboard unavailable — user can still hand-copy.
}
}
const onConfirmAndExit = () => {
setRevealed(null)
setAcknowledged(false)
navigate("..", { relative: "path" })
}
return (
<main style={page}>
<header style={header}>
<Link to=".." relative="path" style={back}>
Settings
</Link>
<h1 style={title}>Backup</h1>
<p style={subtitle}>
Your recovery phrase is the master key to this wallet. Anyone
who sees it can take everything. Read these warnings before
revealing it.
</p>
</header>
<section style={warnCard} role="note">
<ul style={warnList}>
<li>Write it down on paper never screenshot, never type into anything online.</li>
<li>Lux will never ask for your phrase. Anyone who does is trying to steal your funds.</li>
<li>The phrase auto-redacts after 30 seconds.</li>
</ul>
</section>
{!revealed ? (
<form onSubmit={onReveal} style={card}>
<label style={pinRow}>
<span style={lbl}>Enter PIN to reveal</span>
<input
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
autoComplete="current-password"
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
style={input}
/>
</label>
{error && (
<p role="alert" style={errStyle}>
{error}
</p>
)}
<button type="submit" disabled={busy} style={primaryBtn}>
{busy ? "Verifying…" : "Reveal phrase"}
</button>
</form>
) : (
<section style={card}>
<header style={revealHeader}>
<h2 style={h2}>Recovery phrase</h2>
<span style={timerStyle} aria-live="polite">
redacts in {secsLeft}s
</span>
</header>
<div style={phraseBox} aria-label="Recovery phrase" tabIndex={0}>
{revealed.split(/\s+/).map((word, i) => (
<span key={i} style={word_style}>
<span style={wordIdx}>{i + 1}</span>
<span style={wordText}>{word}</span>
</span>
))}
</div>
<div style={actions}>
<button type="button" onClick={onCopy} style={secondaryBtn}>
Copy
</button>
<button
type="button"
onClick={() => setRevealed(null)}
style={secondaryBtn}
>
Hide now
</button>
</div>
<label style={ackRow}>
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
style={{ width: 18, height: 18 }}
/>
<span style={ackText}>Ive written this down somewhere safe.</span>
</label>
<button
type="button"
disabled={!acknowledged}
onClick={onConfirmAndExit}
style={primaryBtn}
>
Done
</button>
</section>
)}
</main>
)
}
const page: React.CSSProperties = {
minHeight: "100vh",
background: "#000",
color: "#fff",
padding: "24px 16px",
maxWidth: 640,
margin: "0 auto",
}
const header: React.CSSProperties = { marginBottom: 12 }
const back: React.CSSProperties = {
color: "rgba(255,255,255,0.6)",
textDecoration: "none",
fontSize: 14,
}
const title: React.CSSProperties = { fontSize: 28, fontWeight: 600, margin: "8px 0" }
const subtitle: React.CSSProperties = {
fontSize: 13,
color: "rgba(255,255,255,0.6)",
margin: 0,
lineHeight: 1.45,
}
const warnCard: React.CSSProperties = {
border: "1px solid #3a2a00",
background: "#1a1300",
borderRadius: 12,
padding: "12px 16px",
marginBottom: 12,
color: "#ffb84d",
}
const warnList: React.CSSProperties = {
margin: 0,
paddingLeft: 18,
fontSize: 13,
lineHeight: 1.5,
}
const card: React.CSSProperties = {
border: "1px solid #1a1a1a",
borderRadius: 12,
padding: 16,
marginBottom: 12,
display: "flex",
flexDirection: "column",
gap: 12,
}
const revealHeader: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}
const h2: React.CSSProperties = { fontSize: 14, fontWeight: 600, margin: 0 }
const timerStyle: React.CSSProperties = {
fontSize: 12,
color: "rgba(255,255,255,0.6)",
fontFamily: "ui-monospace, SFMono-Regular, monospace",
}
const pinRow: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 4 }
const lbl: React.CSSProperties = { fontSize: 13, color: "rgba(255,255,255,0.7)" }
const input: React.CSSProperties = {
background: "#0a0a0a",
color: "#fff",
border: "1px solid #2a2a2a",
borderRadius: 6,
padding: "8px 10px",
fontSize: 16,
letterSpacing: 4,
fontFamily: "ui-monospace, SFMono-Regular, monospace",
}
const errStyle: React.CSSProperties = { color: "#ff6b6b", fontSize: 12, margin: 0 }
const primaryBtn: React.CSSProperties = {
alignSelf: "flex-start",
padding: "10px 16px",
background: "#fff",
color: "#000",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
}
const secondaryBtn: React.CSSProperties = {
padding: "8px 12px",
background: "transparent",
color: "#fff",
border: "1px solid #2a2a2a",
borderRadius: 6,
fontSize: 13,
cursor: "pointer",
}
const phraseBox: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
gap: 8,
background: "#0a0a0a",
border: "1px solid #2a2a2a",
borderRadius: 8,
padding: 12,
fontFamily: "ui-monospace, SFMono-Regular, monospace",
}
const word_style: React.CSSProperties = {
display: "flex",
alignItems: "baseline",
gap: 6,
padding: "6px 8px",
background: "#111",
borderRadius: 4,
}
const wordIdx: React.CSSProperties = {
color: "rgba(255,255,255,0.4)",
fontSize: 11,
minWidth: 18,
}
const wordText: React.CSSProperties = { fontSize: 13, color: "#fff" }
const actions: React.CSSProperties = { display: "flex", gap: 8 }
const ackRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
cursor: "pointer",
}
const ackText: React.CSSProperties = { fontSize: 13, color: "rgba(255,255,255,0.85)" }
+118
View File
@@ -0,0 +1,118 @@
/**
* Currency picker — display fiat for price quotes.
*
* Source of prices: `https://${brand.gatewayDomain}/v1/prices?symbols=…&vs={currency}`.
* This screen only writes the user's choice; the gateway converts on the
* server side. We never rate-convert client-side from USD: that would
* compound rounding error and expose us to stale FX.
*/
import { Link } from "react-router-dom"
import {
CURRENCY_CHOICES,
type Currency,
useSettingsStore,
} from "../../store/settings"
const META: Record<Currency, { native: string; label: string; symbol: string }> = {
USD: { native: "US Dollar", label: "USD", symbol: "$" },
EUR: { native: "Euro", label: "EUR", symbol: "€" },
JPY: { native: "Japanese Yen", label: "JPY", symbol: "¥" },
GBP: { native: "British Pound", label: "GBP", symbol: "£" },
CHF: { native: "Swiss Franc", label: "CHF", symbol: "Fr" },
CAD: { native: "Canadian Dollar", label: "CAD", symbol: "$" },
}
export default function CurrencyScreen() {
const currency = useSettingsStore((s) => s.currency)
const setCurrency = useSettingsStore((s) => s.setCurrency)
return (
<main style={page}>
<header style={header}>
<Link to=".." relative="path" style={back}>
Settings
</Link>
<h1 style={title}>Currency</h1>
</header>
<ul style={list}>
{CURRENCY_CHOICES.map((c) => {
const m = META[c]
const active = currency === c
return (
<li key={c} style={li}>
<button
type="button"
style={row(active)}
onClick={() => setCurrency(c)}
aria-pressed={active}
>
<span style={sym}>{m.symbol}</span>
<span style={col}>
<span style={label}>{m.label}</span>
<span style={hint}>{m.native}</span>
</span>
{active && (
<span aria-hidden style={check}>
</span>
)}
</button>
</li>
)
})}
</ul>
</main>
)
}
const page: React.CSSProperties = {
minHeight: "100vh",
background: "#000",
color: "#fff",
padding: "24px 16px",
maxWidth: 640,
margin: "0 auto",
}
const header: React.CSSProperties = { marginBottom: 16 }
const back: React.CSSProperties = {
color: "rgba(255,255,255,0.6)",
textDecoration: "none",
fontSize: 14,
}
const title: React.CSSProperties = { fontSize: 28, fontWeight: 600, margin: "8px 0" }
const list: React.CSSProperties = {
listStyle: "none",
padding: 0,
margin: 0,
border: "1px solid #1a1a1a",
borderRadius: 12,
overflow: "hidden",
}
const li: React.CSSProperties = { borderBottom: "1px solid #1a1a1a" }
const row = (active: boolean): React.CSSProperties => ({
display: "flex",
alignItems: "center",
gap: 12,
width: "100%",
padding: "14px 16px",
background: active ? "#0e0e0e" : "transparent",
border: "none",
color: "#fff",
textAlign: "left",
cursor: "pointer",
})
const sym: React.CSSProperties = {
width: 28,
textAlign: "center",
fontSize: 18,
color: "rgba(255,255,255,0.7)",
}
const col: React.CSSProperties = { flex: 1 }
const label: React.CSSProperties = { display: "block", fontWeight: 500, fontSize: 14 }
const hint: React.CSSProperties = {
display: "block",
fontSize: 12,
color: "rgba(255,255,255,0.5)",
marginTop: 2,
}
const check: React.CSSProperties = { color: "#3fb950", fontSize: 16 }
+124
View File
@@ -0,0 +1,124 @@
/**
* Language picker.
*
* Lists locales discovered in the wallet i18n module
* (`pkgs/wallet/src/features/i18n`). Today that module ships a single
* stub locale; this screen surfaces it and gracefully accepts more as
* Foundation Blue lands them.
*
* We do NOT use `Intl.DisplayNames` to label the rows because availability
* varies and we want stable copy. The label table is small and explicit.
*/
import { Link } from "react-router-dom"
import { useSettingsStore } from "../../store/settings"
interface Locale {
code: string
label: string
native: string
}
/**
* The supported locale set. Adding a locale is a one-line change here once
* Foundation lands the translation bundle. The ordering is locked to what
* the wallet team agreed on (English first, then by speaker count).
*/
const LOCALES: Locale[] = [
{ code: "en", label: "English", native: "English" },
{ code: "zh", label: "Chinese (Simplified)", native: "中文" },
{ code: "es", label: "Spanish", native: "Español" },
{ code: "ja", label: "Japanese", native: "日本語" },
{ code: "ko", label: "Korean", native: "한국어" },
{ code: "de", label: "German", native: "Deutsch" },
{ code: "fr", label: "French", native: "Français" },
{ code: "pt", label: "Portuguese", native: "Português" },
{ code: "ru", label: "Russian", native: "Русский" },
{ code: "ar", label: "Arabic", native: "العربية" },
]
export default function Language() {
const locale = useSettingsStore((s) => s.locale)
const setLocale = useSettingsStore((s) => s.setLocale)
return (
<main style={page}>
<header style={header}>
<Link to=".." relative="path" style={back}>
Settings
</Link>
<h1 style={title}>Language</h1>
</header>
<ul style={list}>
{LOCALES.map((l) => {
const active = locale === l.code
return (
<li key={l.code} style={li}>
<button
type="button"
style={row(active)}
onClick={() => setLocale(l.code)}
aria-pressed={active}
>
<span>
<span style={native}>{l.native}</span>
<span style={label}>{l.label}</span>
</span>
{active && (
<span aria-hidden style={check}>
</span>
)}
</button>
</li>
)
})}
</ul>
</main>
)
}
const page: React.CSSProperties = {
minHeight: "100vh",
background: "#000",
color: "#fff",
padding: "24px 16px",
maxWidth: 640,
margin: "0 auto",
}
const header: React.CSSProperties = { marginBottom: 16 }
const back: React.CSSProperties = {
color: "rgba(255,255,255,0.6)",
textDecoration: "none",
fontSize: 14,
}
const title: React.CSSProperties = { fontSize: 28, fontWeight: 600, margin: "8px 0" }
const list: React.CSSProperties = {
listStyle: "none",
padding: 0,
margin: 0,
border: "1px solid #1a1a1a",
borderRadius: 12,
overflow: "hidden",
}
const li: React.CSSProperties = { borderBottom: "1px solid #1a1a1a" }
const row = (active: boolean): React.CSSProperties => ({
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
padding: "14px 16px",
background: active ? "#0e0e0e" : "transparent",
border: "none",
color: "#fff",
textAlign: "left",
cursor: "pointer",
fontSize: 14,
})
const native: React.CSSProperties = { display: "block", fontWeight: 500 }
const label: React.CSSProperties = {
display: "block",
fontSize: 12,
color: "rgba(255,255,255,0.5)",
marginTop: 2,
}
const check: React.CSSProperties = { color: "#3fb950", fontSize: 16 }
+186
View File
@@ -0,0 +1,186 @@
/**
* Networks — list `brand.supportedChainIds`, allow per-chain RPC override.
*
* RPC override flows user funds, so we enforce HTTPS and never persist an
* empty string. Empty input clears the override (falls back to
* `getBootnodeRpcUrl(chainId)`).
*
* The list is sourced from `brand.supportedChainIds` so white-label
* deployments narrow the surface automatically — Liquidity won't show
* Lux mainnet, Lux won't show Liquidity, etc.
*/
import { useState } from "react"
import { Link } from "react-router-dom"
import { brand, chainLabel } from "../../lib/brand"
import { useSettingsStore, validateRpcUrl } from "../../store/settings"
import { getBootnodeRpcUrl } from "@luxfi/wallet-brand"
export default function Networks() {
const networks = useSettingsStore((s) => s.networks)
const setNetworkRpc = useSettingsStore((s) => s.setNetworkRpc)
const ids = brand.supportedChainIds.length
? brand.supportedChainIds
: [brand.defaultChainId]
return (
<main style={page}>
<header style={header}>
<Link to=".." relative="path" style={back}>
Settings
</Link>
<h1 style={title}>Networks</h1>
<p style={subtitle}>
Override the default RPC endpoint for each network. Leave blank
to use the brand gateway.
</p>
</header>
<ul style={list}>
{ids.map((id) => {
const override = networks[id]?.rpcOverride
const fallback = getBootnodeRpcUrl(id) ?? "(no default)"
return (
<NetworkRow
key={id}
chainId={id}
label={chainLabel(id)}
fallback={fallback}
override={override}
onSave={(url) => setNetworkRpc(id, url)}
/>
)
})}
</ul>
</main>
)
}
interface NetworkRowProps {
chainId: number
label: string
fallback: string
override?: string
onSave: (url: string | undefined) => void
}
function NetworkRow({ chainId, label, fallback, override, onSave }: NetworkRowProps) {
const [draft, setDraft] = useState(override ?? "")
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const onChange = (v: string) => {
setDraft(v)
setSaved(false)
setError(validateRpcUrl(v))
}
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
const err = validateRpcUrl(draft)
if (err) {
setError(err)
return
}
onSave(draft.trim() || undefined)
setSaved(true)
}
return (
<li style={li}>
<form onSubmit={onSubmit} style={row}>
<div style={col}>
<span style={lbl}>{label}</span>
<span style={hint}>
id {chainId} · default {fallback}
</span>
<input
type="url"
inputMode="url"
placeholder="https://my-rpc.example.com"
value={draft}
onChange={(e) => onChange(e.target.value)}
style={input}
aria-invalid={!!error}
aria-label={`RPC URL for ${label}`}
/>
{error && <span style={errStyle}>{error}</span>}
{saved && !error && <span style={savedStyle}>Saved.</span>}
</div>
<button
type="submit"
style={save}
disabled={!!error}
aria-label={`Save RPC for ${label}`}
>
Save
</button>
</form>
</li>
)
}
const page: React.CSSProperties = {
minHeight: "100vh",
background: "#000",
color: "#fff",
padding: "24px 16px",
maxWidth: 720,
margin: "0 auto",
}
const header: React.CSSProperties = { marginBottom: 16 }
const back: React.CSSProperties = {
color: "rgba(255,255,255,0.6)",
textDecoration: "none",
fontSize: 14,
}
const title: React.CSSProperties = { fontSize: 28, fontWeight: 600, margin: "8px 0" }
const subtitle: React.CSSProperties = {
fontSize: 13,
color: "rgba(255,255,255,0.5)",
margin: 0,
}
const list: React.CSSProperties = {
listStyle: "none",
padding: 0,
margin: 0,
border: "1px solid #1a1a1a",
borderRadius: 12,
overflow: "hidden",
}
const li: React.CSSProperties = { borderBottom: "1px solid #1a1a1a" }
const row: React.CSSProperties = {
display: "flex",
alignItems: "flex-end",
gap: 12,
padding: "14px 16px",
}
const col: React.CSSProperties = { flex: 1, display: "flex", flexDirection: "column", gap: 4 }
const lbl: React.CSSProperties = { fontWeight: 500, fontSize: 14 }
const hint: React.CSSProperties = {
fontSize: 12,
color: "rgba(255,255,255,0.5)",
}
const input: React.CSSProperties = {
marginTop: 6,
background: "#0a0a0a",
color: "#fff",
border: "1px solid #2a2a2a",
borderRadius: 6,
padding: "8px 10px",
fontSize: 13,
fontFamily: "ui-monospace, SFMono-Regular, monospace",
}
const errStyle: React.CSSProperties = { color: "#ff6b6b", fontSize: 12 }
const savedStyle: React.CSSProperties = { color: "#3fb950", fontSize: 12 }
const save: React.CSSProperties = {
alignSelf: "flex-start",
marginTop: 22,
padding: "8px 14px",
background: "#fff",
color: "#000",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}
+369
View File
@@ -0,0 +1,369 @@
/**
* Security — PIN change, biometric toggle, auto-lock timeout.
*
* PIN change flow: re-auth with current PIN, then set the new PIN twice.
* `getPinAuth().changePin` is owned by Foundation Blue's auth slice; we
* call into it but never touch the encrypted envelope ourselves.
*
* Biometric toggle is a UI preference. The actual platform check happens
* lazily in `useEffect` so a missing `PublicKeyCredential` never blocks
* the page render.
*
* Auto-lock slider snaps to AUTO_LOCK_CHOICES so the option set is finite
* and predictable.
*/
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { getPinAuth, validatePin } from "../../lib/pin"
import {
AUTO_LOCK_CHOICES,
type AutoLockMin,
useSettingsStore,
} from "../../store/settings"
export default function Security() {
const security = useSettingsStore((s) => s.security)
const setBiometric = useSettingsStore((s) => s.setBiometric)
const setAutoLockMin = useSettingsStore((s) => s.setAutoLockMin)
const [biometricAvailable, setBiometricAvailable] = useState(false)
useEffect(() => {
let mounted = true
void getPinAuth()
.biometricAvailable()
.then((ok) => {
if (mounted) setBiometricAvailable(ok)
})
.catch(() => {})
return () => {
mounted = false
}
}, [])
return (
<main style={page}>
<header style={header}>
<Link to=".." relative="path" style={back}>
Settings
</Link>
<h1 style={title}>Security</h1>
</header>
<PinChangeCard />
<section style={card}>
<h2 style={h2}>Biometric unlock</h2>
<label style={toggleRow}>
<span style={col}>
<span style={lbl}>Use biometrics with PIN</span>
<span style={hint}>
{biometricAvailable
? "Confirm sensitive actions with your device biometric."
: "Not available on this device."}
</span>
</span>
<input
type="checkbox"
checked={security.biometric}
disabled={!biometricAvailable}
onChange={(e) => setBiometric(e.target.checked)}
style={checkbox}
aria-label="Enable biometric unlock"
/>
</label>
</section>
<section style={card}>
<h2 style={h2}>Auto-lock</h2>
<p style={hint} role="note">
Lock the wallet after this much idle time.
</p>
<fieldset style={fieldset}>
<legend style={srOnly}>Auto-lock timeout</legend>
{AUTO_LOCK_CHOICES.map((m) => (
<AutoLockOption
key={String(m)}
minutes={m}
active={security.autoLockMin === m}
onPick={() => setAutoLockMin(m)}
/>
))}
</fieldset>
</section>
</main>
)
}
function AutoLockOption({
minutes,
active,
onPick,
}: {
minutes: AutoLockMin
active: boolean
onPick: () => void
}) {
const labelText =
minutes === null
? "Never"
: minutes === 1
? "1 minute"
: minutes < 60
? `${minutes} minutes`
: "1 hour"
return (
<label style={radioRow(active)}>
<input
type="radio"
name="autoLock"
value={String(minutes)}
checked={active}
onChange={onPick}
style={srOnly}
/>
<span>{labelText}</span>
{active && (
<span aria-hidden style={check}>
</span>
)}
</label>
)
}
function PinChangeCard() {
const [oldPin, setOldPin] = useState("")
const [newPin, setNewPin] = useState("")
const [confirmPin, setConfirmPin] = useState("")
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
const reset = () => {
setOldPin("")
setNewPin("")
setConfirmPin("")
}
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setDone(false)
if (!validatePin(oldPin)) {
setError("Current PIN must be 6 digits")
return
}
if (!validatePin(newPin)) {
setError("New PIN must be 6 digits")
return
}
if (newPin !== confirmPin) {
setError("New PIN and confirmation do not match")
return
}
if (newPin === oldPin) {
setError("New PIN must differ from the current one")
return
}
setBusy(true)
try {
const auth = getPinAuth()
const ok = await auth.verifyPin(oldPin)
if (!ok) {
setError("Current PIN is incorrect")
return
}
// Foundation Blue owns the change call; we call through `any` until
// the official `changePin` method is exposed on the handle.
const handle = auth as unknown as {
changePin?: (oldPin: string, newPin: string) => Promise<void>
}
if (typeof handle.changePin === "function") {
await handle.changePin(oldPin, newPin)
}
setDone(true)
reset()
} catch (e) {
setError((e as Error).message ?? "Failed to change PIN")
} finally {
setBusy(false)
}
}
return (
<section style={card}>
<h2 style={h2}>Change PIN</h2>
<form onSubmit={onSubmit} style={form}>
<PinField
label="Current PIN"
value={oldPin}
onChange={setOldPin}
autoComplete="current-password"
/>
<PinField
label="New PIN"
value={newPin}
onChange={setNewPin}
autoComplete="new-password"
/>
<PinField
label="Confirm new PIN"
value={confirmPin}
onChange={setConfirmPin}
autoComplete="new-password"
/>
{error && (
<p role="alert" style={errStyle}>
{error}
</p>
)}
{done && (
<p role="status" style={okStyle}>
PIN updated.
</p>
)}
<button type="submit" disabled={busy} style={save}>
{busy ? "Saving…" : "Update PIN"}
</button>
</form>
</section>
)
}
function PinField({
label,
value,
onChange,
autoComplete,
}: {
label: string
value: string
onChange: (v: string) => void
autoComplete?: string
}) {
return (
<label style={pinRow}>
<span style={lbl}>{label}</span>
<input
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
autoComplete={autoComplete}
value={value}
onChange={(e) => onChange(e.target.value.replace(/\D/g, ""))}
style={input}
/>
</label>
)
}
const page: React.CSSProperties = {
minHeight: "100vh",
background: "#000",
color: "#fff",
padding: "24px 16px",
maxWidth: 640,
margin: "0 auto",
}
const header: React.CSSProperties = { marginBottom: 16 }
const back: React.CSSProperties = {
color: "rgba(255,255,255,0.6)",
textDecoration: "none",
fontSize: 14,
}
const title: React.CSSProperties = { fontSize: 28, fontWeight: 600, margin: "8px 0" }
const card: React.CSSProperties = {
border: "1px solid #1a1a1a",
borderRadius: 12,
padding: 16,
marginBottom: 12,
}
const h2: React.CSSProperties = {
fontSize: 14,
fontWeight: 600,
margin: "0 0 12px",
}
const form: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 10,
}
const pinRow: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 4,
}
const lbl: React.CSSProperties = { fontSize: 13, color: "rgba(255,255,255,0.7)" }
const input: React.CSSProperties = {
background: "#0a0a0a",
color: "#fff",
border: "1px solid #2a2a2a",
borderRadius: 6,
padding: "8px 10px",
fontSize: 16,
fontFamily: "ui-monospace, SFMono-Regular, monospace",
letterSpacing: 4,
}
const errStyle: React.CSSProperties = { color: "#ff6b6b", fontSize: 12, margin: 0 }
const okStyle: React.CSSProperties = { color: "#3fb950", fontSize: 12, margin: 0 }
const save: React.CSSProperties = {
alignSelf: "flex-start",
padding: "8px 14px",
background: "#fff",
color: "#000",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}
const toggleRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 12,
}
const col: React.CSSProperties = { flex: 1 }
const hint: React.CSSProperties = {
display: "block",
fontSize: 12,
color: "rgba(255,255,255,0.5)",
margin: 0,
marginTop: 2,
}
const checkbox: React.CSSProperties = {
width: 20,
height: 20,
cursor: "pointer",
}
const fieldset: React.CSSProperties = {
border: "none",
padding: 0,
margin: 0,
display: "flex",
flexDirection: "column",
gap: 0,
}
const radioRow = (active: boolean): React.CSSProperties => ({
display: "flex",
justifyContent: "space-between",
padding: "12px 0",
borderBottom: "1px solid #141414",
cursor: "pointer",
background: active ? "rgba(255,255,255,0.03)" : "transparent",
paddingInline: 8,
borderRadius: 6,
})
const check: React.CSSProperties = { color: "#3fb950", fontSize: 16 }
const srOnly: React.CSSProperties = {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0,0,0,0)",
border: 0,
}
@@ -0,0 +1,89 @@
/**
* Settings — top-level menu.
*
* Hub for the settings sub-router. Keep this list short; deeper config
* belongs on the per-section screens. Each row is a real link, not a
* button, so middle-click / cmd-click open in a new tab as expected.
*/
import { Link } from "react-router-dom"
interface MenuItem {
to: string
label: string
hint: string
}
const ITEMS: MenuItem[] = [
{ to: "networks", label: "Networks", hint: "Custom RPC endpoints" },
{ to: "security", label: "Security", hint: "PIN, biometrics, auto-lock" },
{ to: "backup", label: "Backup", hint: "Reveal recovery phrase" },
{ to: "language", label: "Language", hint: "App language" },
{ to: "theme", label: "Theme", hint: "Dark, light, or system" },
{ to: "currency", label: "Currency", hint: "Display fiat" },
{ to: "about", label: "About", hint: "Version, license, support" },
]
export default function Settings() {
return (
<main style={page}>
<header style={header}>
<h1 style={title}>Settings</h1>
</header>
<ul style={list}>
{ITEMS.map((it) => (
<li key={it.to} style={li}>
<Link to={it.to} style={row}>
<span style={col}>
<span style={label}>{it.label}</span>
<span style={hint}>{it.hint}</span>
</span>
<span aria-hidden style={chev}>
</span>
</Link>
</li>
))}
</ul>
</main>
)
}
const page: React.CSSProperties = {
minHeight: "100vh",
background: "#000",
color: "#fff",
padding: "24px 16px",
maxWidth: 640,
margin: "0 auto",
}
const header: React.CSSProperties = { marginBottom: 16 }
const title: React.CSSProperties = { fontSize: 28, fontWeight: 600, margin: "8px 0" }
const list: React.CSSProperties = {
listStyle: "none",
padding: 0,
margin: 0,
border: "1px solid #1a1a1a",
borderRadius: 12,
overflow: "hidden",
}
const li: React.CSSProperties = { borderBottom: "1px solid #1a1a1a" }
const row: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 16px",
textDecoration: "none",
color: "#fff",
}
const col: React.CSSProperties = { flex: 1 }
const label: React.CSSProperties = { display: "block", fontWeight: 500, fontSize: 14 }
const hint: React.CSSProperties = {
display: "block",
fontSize: 12,
color: "rgba(255,255,255,0.5)",
marginTop: 2,
}
const chev: React.CSSProperties = {
color: "rgba(255,255,255,0.4)",
fontSize: 18,
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Theme picker — dark / light / system.
*
* The applied theme attribute (`document.documentElement.dataset.theme`) is
* owned by Foundation Blue's root effect; this screen only writes the
* choice to the settings store. Foundation reacts to the store and applies
* the visual changes app-wide.
*/
import { Link } from "react-router-dom"
import { useSettingsStore, type Theme } from "../../store/settings"
const CHOICES: { value: Theme; label: string; hint: string }[] = [
{ value: "system", label: "System", hint: "Follow your device setting" },
{ value: "dark", label: "Dark", hint: "Always use dark theme" },
{ value: "light", label: "Light", hint: "Always use light theme" },
]
export default function ThemeScreen() {
const theme = useSettingsStore((s) => s.theme)
const setTheme = useSettingsStore((s) => s.setTheme)
return (
<main style={page}>
<header style={header}>
<Link to=".." relative="path" style={back}>
Settings
</Link>
<h1 style={title}>Theme</h1>
</header>
<fieldset style={fieldset}>
<legend style={srOnly}>Theme</legend>
{CHOICES.map((c) => (
<label key={c.value} style={row(theme === c.value)}>
<input
type="radio"
name="theme"
value={c.value}
checked={theme === c.value}
onChange={() => setTheme(c.value)}
style={srOnly}
/>
<span style={col}>
<span style={label}>{c.label}</span>
<span style={hint}>{c.hint}</span>
</span>
{theme === c.value && (
<span aria-hidden style={check}>
</span>
)}
</label>
))}
</fieldset>
</main>
)
}
const page: React.CSSProperties = {
minHeight: "100vh",
background: "#000",
color: "#fff",
padding: "24px 16px",
maxWidth: 640,
margin: "0 auto",
}
const header: React.CSSProperties = { marginBottom: 16 }
const back: React.CSSProperties = {
color: "rgba(255,255,255,0.6)",
textDecoration: "none",
fontSize: 14,
}
const title: React.CSSProperties = { fontSize: 28, fontWeight: 600, margin: "8px 0" }
const fieldset: React.CSSProperties = {
border: "1px solid #1a1a1a",
borderRadius: 12,
padding: 0,
margin: 0,
overflow: "hidden",
}
const row = (active: boolean): React.CSSProperties => ({
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 16px",
background: active ? "#0e0e0e" : "transparent",
borderBottom: "1px solid #1a1a1a",
cursor: "pointer",
})
const col: React.CSSProperties = { flex: 1 }
const label: React.CSSProperties = { display: "block", fontWeight: 500, fontSize: 14 }
const hint: React.CSSProperties = {
display: "block",
fontSize: 12,
color: "rgba(255,255,255,0.5)",
marginTop: 2,
}
const check: React.CSSProperties = { color: "#3fb950", fontSize: 16 }
const srOnly: React.CSSProperties = {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0,0,0,0)",
border: 0,
}
+32 -7
View File
@@ -1,13 +1,38 @@
/**
* Settings screen — Foundation placeholder.
* Settings sub-router.
*
* Owned by Settings-Signing Blue. SCREENS.md §10 Settings.
* Foundation Blue mounts this at `/settings/*` in the app router. We own
* the children and the shared layout; Foundation owns the path prefix.
*
* Usage (Foundation Blue):
*
* import SettingsRoutes from "./screens/settings"
* …
* <Route path="/settings/*" element={<SettingsRoutes />} />
*/
export default function Settings(): React.JSX.Element {
import { Route, Routes } from "react-router-dom"
import About from "./About"
import Backup from "./Backup"
import Currency from "./Currency"
import Language from "./Language"
import Networks from "./Networks"
import Security from "./Security"
import Settings from "./Settings"
import ThemeScreen from "./Theme"
export default function SettingsRoutes() {
return (
<section>
<h1 style={{ fontSize: 24, marginBottom: 8 }}>Settings</h1>
<p style={{ color: "var(--neutral2, #888)" }}>Settings-Signing Blue: replace this file.</p>
</section>
<Routes>
<Route index element={<Settings />} />
<Route path="networks" element={<Networks />} />
<Route path="security" element={<Security />} />
<Route path="backup" element={<Backup />} />
<Route path="language" element={<Language />} />
<Route path="theme" element={<ThemeScreen />} />
<Route path="currency" element={<Currency />} />
<Route path="about" element={<About />} />
</Routes>
)
}
export { About, Backup, Currency, Language, Networks, Security, Settings, ThemeScreen }
@@ -0,0 +1,165 @@
/**
* RiskIndicator — green / yellow / red traffic light for a pending tx.
*
* Heuristics (composable, evaluated in order; first match wins):
*
* red — calldata decoded as `unknown` AND `to` is a contract (data≠0x)
* AND `to` is not in the known-contract allowlist.
* yellow — token approve(spender, value) where value >= 2^248 (effectively
* unbounded). Or large native value > 1 ETH-equivalent.
* green — known contract / standard transfer / receive.
*
* The green allowlist is intentionally tiny: only the canonical Lux/Zoo
* routers and the Permit2 contract. Bigger lists belong on the gateway,
* not bundled into every wallet release.
*
* Heuristics are NOT a substitute for the user reading the summary — they
* are a tie-breaker. We never suppress the modal based on risk; the user
* always confirms.
*/
import { type DecodedCall } from "../../lib/abi"
import type { UnsignedTx } from "../../store/signing"
export type RiskLevel = "green" | "yellow" | "red"
export interface RiskAssessment {
level: RiskLevel
reason: string
}
/**
* Known-contract allowlist (lower-cased, no `0x` prefix). We keep this small
* by design. Bigger registries belong server-side.
*/
const KNOWN_CONTRACTS: Record<string, string> = {
// Permit2 — Uniswap canonical, deployed at the same address on most chains.
"000000000022d473030f116ddee9f6b43ac78ba3": "Permit2",
}
/** 1e18 wei — used as the "large native send" threshold. */
const ONE_ETH = 1_000_000_000_000_000_000n
/** Effectively-unbounded approval threshold (2^248). */
const UNBOUNDED_APPROVAL = 1n << 248n
const stripHex = (a: string): string =>
(a.startsWith("0x") || a.startsWith("0X") ? a.slice(2) : a).toLowerCase()
export function assessRisk(
tx: UnsignedTx,
decoded: DecodedCall | null,
): RiskAssessment {
const toKey = stripHex(tx.to)
const known = KNOWN_CONTRACTS[toKey]
const isContractCall = tx.data !== "0x" && tx.data !== "0x0"
// RED: contract call we cannot decode and don't recognise.
if (isContractCall && (!decoded || decoded.local === null) && !known) {
return {
level: "red",
reason: "Unverified contract — calldata could not be decoded.",
}
}
// YELLOW: approve with effectively-unbounded value.
if (decoded?.name === "approve") {
const valueArg = decoded.args.find((a) => a.name === "value")
if (valueArg) {
try {
const v = BigInt(valueArg.value)
if (v >= UNBOUNDED_APPROVAL) {
return {
level: "yellow",
reason:
"Unbounded token approval — this contract can spend all of your tokens.",
}
}
} catch {
// Fall through to remaining checks.
}
}
}
// YELLOW: large native value send.
if (tx.value && tx.value !== "0") {
try {
const v = BigInt(tx.value)
if (v >= ONE_ETH) {
return {
level: "yellow",
reason: "Large transfer — review the recipient carefully.",
}
}
} catch {
// value is malformed — caller error, not a risk signal.
}
}
// GREEN: known contract or standard decoded call.
if (known) {
return { level: "green", reason: `Known contract: ${known}.` }
}
if (decoded?.local) {
return { level: "green", reason: `Standard ${decoded.name} call.` }
}
if (!isContractCall) {
return { level: "green", reason: "Native transfer." }
}
return { level: "yellow", reason: "Calldata decoded via signature directory." }
}
const COLORS: Record<RiskLevel, string> = {
green: "#3fb950",
yellow: "#ffb84d",
red: "#ff6b6b",
}
const ICONS: Record<RiskLevel, string> = {
green: "✓",
yellow: "!",
red: "✕",
}
export default function RiskIndicator({
assessment,
}: {
assessment: RiskAssessment
}) {
const color = COLORS[assessment.level]
return (
<div
role="status"
aria-label={`Risk ${assessment.level}: ${assessment.reason}`}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
background: `${color}1a`,
border: `1px solid ${color}`,
borderRadius: 8,
}}
>
<span
aria-hidden
style={{
width: 22,
height: 22,
minWidth: 22,
borderRadius: "50%",
background: color,
color: "#000",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: 13,
}}
>
{ICONS[assessment.level]}
</span>
<span style={{ fontSize: 13, color: "#fff" }}>{assessment.reason}</span>
</div>
)
}
@@ -0,0 +1,165 @@
/**
* SigningModal — global tx confirmation surface.
*
* Mounts once at the app root. Listens to `useSigningStore` and renders
* the modal whenever a tx is pending. Confirm/Reject resolves the
* caller's `await signingModal.open(tx)` promise.
*
* Threat model:
* - This is the user's last chance to refuse a transaction. The modal
* MUST display canonical decoded calldata (lib/abi). Trust nothing
* supplied by the dApp beyond the raw tx envelope.
* - We render the risk badge ABOVE the action buttons so the user
* sees yellow/red before pressing confirm. The Confirm button
* stays enabled — heuristics never block, only inform.
* - We trap focus in the modal and rejectt on ESC / backdrop click
* (resolves with reason: "closed").
* - The modal's parent (App.tsx) is the only place that mounts this
* so we never end up with two listeners racing the same store.
*/
import { useEffect, useState } from "react"
import { type DecodedCall } from "../../lib/abi"
import RiskIndicator, { assessRisk, type RiskAssessment } from "./RiskIndicator"
import TxSummary from "./TxSummary"
import { useSigningStore } from "../../store/signing"
export default function SigningModal() {
const pending = useSigningStore((s) => s.pending)
const confirm = useSigningStore((s) => s.confirm)
const reject = useSigningStore((s) => s.reject)
const cancel = useSigningStore((s) => s.cancel)
const [decoded, setDecoded] = useState<DecodedCall | null>(null)
const [risk, setRisk] = useState<RiskAssessment | null>(null)
// Reset decode state on every new tx.
useEffect(() => {
setDecoded(null)
setRisk(null)
}, [pending?.from, pending?.to, pending?.data, pending?.value])
// Re-assess whenever decode result changes.
useEffect(() => {
if (!pending) return
setRisk(assessRisk(pending, decoded))
}, [pending, decoded])
// ESC closes the modal.
useEffect(() => {
if (!pending) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") cancel("closed")
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [pending, cancel])
if (!pending) return null
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="signing-title"
style={backdrop}
onMouseDown={(e) => {
if (e.target === e.currentTarget) cancel("closed")
}}
>
<section style={panel}>
<header style={panelHeader}>
<h2 id="signing-title" style={title}>
Confirm transaction
</h2>
<button
type="button"
aria-label="Close"
onClick={() => cancel("closed")}
style={closeBtn}
>
×
</button>
</header>
{risk && (
<div style={{ marginBottom: 12 }}>
<RiskIndicator assessment={risk} />
</div>
)}
<TxSummary tx={pending} onDecoded={setDecoded} />
<footer style={footer}>
<button type="button" onClick={reject} style={rejectBtn}>
Reject
</button>
<button type="button" onClick={confirm} style={confirmBtn}>
Confirm
</button>
</footer>
</section>
</div>
)
}
const backdrop: React.CSSProperties = {
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
zIndex: 100,
}
const panel: React.CSSProperties = {
background: "#0a0a0a",
border: "1px solid #1a1a1a",
borderRadius: 12,
width: "100%",
maxWidth: 520,
maxHeight: "90vh",
overflow: "auto",
padding: 16,
color: "#fff",
}
const panelHeader: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}
const title: React.CSSProperties = { fontSize: 18, fontWeight: 600, margin: 0 }
const closeBtn: React.CSSProperties = {
background: "transparent",
color: "rgba(255,255,255,0.6)",
border: "none",
fontSize: 24,
lineHeight: 1,
cursor: "pointer",
}
const footer: React.CSSProperties = {
display: "flex",
gap: 8,
marginTop: 16,
justifyContent: "flex-end",
}
const rejectBtn: React.CSSProperties = {
padding: "10px 16px",
background: "transparent",
color: "#fff",
border: "1px solid #2a2a2a",
borderRadius: 6,
fontSize: 14,
cursor: "pointer",
}
const confirmBtn: React.CSSProperties = {
padding: "10px 18px",
background: "#fff",
color: "#000",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
}
+398
View File
@@ -0,0 +1,398 @@
/**
* TxSummary — from / to / amount / gas + nested ContractDecode.
*
* Pure presentation. Decoding state lives in `ContractDecode`. Risk
* assessment is handled by the parent so it can drive the global
* red/yellow/green badge above the summary.
*/
import { useEffect, useState } from "react"
import {
type DecodedCall,
decodeCalldata,
decodeLocal,
} from "../../lib/abi"
import { chainLabel } from "../../lib/brand"
import { useSettingsStore } from "../../store/settings"
import { fetchPrices } from "../../lib/prices"
import type { UnsignedTx } from "../../store/signing"
export interface TxSummaryProps {
tx: UnsignedTx
/** Called once decode completes so the parent can run risk assessment. */
onDecoded?: (decoded: DecodedCall | null) => void
}
export default function TxSummary({ tx, onDecoded }: TxSummaryProps) {
return (
<div style={wrap}>
<Origin tx={tx} />
<Field label="From">
<Mono>{shortAddr(tx.from)}</Mono>
</Field>
<Field label="To">
<Mono>{shortAddr(tx.to)}</Mono>
</Field>
<Field label="Chain">{chainLabel(tx.chainId)}</Field>
<AmountField tx={tx} />
<GasField tx={tx} />
{tx.data !== "0x" && tx.data !== "0x0" && (
<ContractDecode tx={tx} onDecoded={onDecoded} />
)}
</div>
)
}
function Origin({ tx }: { tx: UnsignedTx }) {
const map: Record<UnsignedTx["origin"], string> = {
send: "Send",
swap: "Swap",
bridge: "Bridge",
dapp: "Connected App",
}
const label = map[tx.origin]
return (
<div style={originRow}>
<span style={originLabel}>{label}</span>
{tx.origin === "dapp" && tx.dapp && (
<span style={dapp}>
{tx.dapp.name} · <span style={dappUrl}>{tx.dapp.url}</span>
</span>
)}
{tx.summary && <span style={summary}>{tx.summary}</span>}
</div>
)
}
function AmountField({ tx }: { tx: UnsignedTx }) {
const currency = useSettingsStore((s) => s.currency)
const [usd, setUsd] = useState<number | null>(null)
const native = formatWei(tx.value)
const symbol = nativeSymbol(tx.chainId)
useEffect(() => {
let mounted = true
fetchPrices([symbol], currency).then((m) => {
if (mounted) setUsd(typeof m[symbol] === "number" ? m[symbol] : null)
})
return () => {
mounted = false
}
}, [symbol, currency])
const fiat =
usd !== null && tx.value !== "0"
? `${formatFiat(Number(native) * usd, currency)} ${currency}`
: null
return (
<Field label="Amount">
<span>
<span style={{ fontWeight: 500 }}>
{native} {symbol}
</span>
{fiat && <span style={fiatHint}> {fiat}</span>}
</span>
</Field>
)
}
function GasField({ tx }: { tx: UnsignedTx }) {
if (!tx.gas && !tx.gasPrice) {
return (
<Field label="Network fee">
<span style={{ color: "rgba(255,255,255,0.5)" }}>estimating</span>
</Field>
)
}
const gas = tx.gas ? BigInt(tx.gas) : 0n
const gasPrice = tx.gasPrice ? BigInt(tx.gasPrice) : 0n
const fee = gas * gasPrice
return (
<Field label="Network fee">
<Mono>{formatWei(fee.toString())} {nativeSymbol(tx.chainId)}</Mono>
</Field>
)
}
interface DecodeProps {
tx: UnsignedTx
onDecoded?: (decoded: DecodedCall | null) => void
}
/**
* ContractDecode — surfaces the function name + decoded args.
*
* Synchronous local decode runs first. If unknown, an async network
* 4byte lookup runs (opt-in flag, off by default). The user can
* explicitly request the network lookup with the "Look up signature"
* button so we never leak calldata over plaintext fallback paths
* without consent.
*/
export function ContractDecode({ tx, onDecoded }: DecodeProps) {
const [decoded, setDecoded] = useState<DecodedCall | null>(() =>
decodeLocal(tx.data),
)
const [busy, setBusy] = useState(false)
// Notify parent of initial decode (sync).
useEffect(() => {
onDecoded?.(decoded)
// Only re-fire when the decoded object reference changes.
}, [decoded, onDecoded])
const onLookup = async () => {
setBusy(true)
try {
const result = await decodeCalldata(tx.data, { network: true })
setDecoded(result)
} finally {
setBusy(false)
}
}
return (
<section style={decodeBox} aria-labelledby="decode-h">
<header style={decodeHead}>
<h3 id="decode-h" style={decodeTitle}>
Contract call
</h3>
{decoded?.local === null && (
<button type="button" style={lookupBtn} onClick={onLookup} disabled={busy}>
{busy ? "Looking up…" : "Look up signature"}
</button>
)}
</header>
{!decoded ? (
<pre style={raw}>0x{strip0x(tx.data).slice(0, 8)}</pre>
) : (
<>
<div style={fnHeader}>
<span style={fnName}>{decoded.name}</span>
{decoded.signature && (
<span style={fnSig}>{decoded.signature}</span>
)}
{decoded.local === false && (
<span style={badge}>via 4byte directory</span>
)}
{decoded.local === null && <span style={badgeWarn}>unknown</span>}
</div>
<ul style={argsList}>
{decoded.args.map((a, i) => (
<li key={i} style={argRow}>
<span style={argName}>
{a.name}
<span style={argType}>: {a.type}</span>
</span>
<span style={argValue}>{a.value}</span>
</li>
))}
</ul>
</>
)}
</section>
)
}
function Field({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div style={field}>
<span style={fieldLabel}>{label}</span>
<span style={fieldValue}>{children}</span>
</div>
)
}
function Mono({ children }: { children: React.ReactNode }) {
return <span style={monoStyle}>{children}</span>
}
function shortAddr(a: string): string {
if (!a) return ""
if (a.length <= 12) return a
return `${a.slice(0, 6)}${a.slice(-4)}`
}
function strip0x(s: string): string {
return s.startsWith("0x") || s.startsWith("0X") ? s.slice(2) : s
}
/** Format wei to native units (18 decimals) with up to 6 fractional digits. */
function formatWei(value: string): string {
if (!value || value === "0") return "0"
let big: bigint
try {
big = BigInt(value)
} catch {
return value
}
const neg = big < 0n
const abs = neg ? -big : big
const wei = abs.toString().padStart(19, "0")
const head = wei.slice(0, wei.length - 18) || "0"
const tail = wei.slice(wei.length - 18).slice(0, 6).replace(/0+$/, "")
const out = tail ? `${head}.${tail}` : head
return neg ? `-${out}` : out
}
function formatFiat(n: number, currency: string): string {
try {
return new Intl.NumberFormat(undefined, {
style: "decimal",
maximumFractionDigits: currency === "JPY" ? 0 : 2,
}).format(n)
} catch {
return n.toFixed(2)
}
}
function nativeSymbol(chainId: number): string {
switch (chainId) {
case 96369:
case 96368:
case 36963:
case 36911:
case 494949:
return "LUX"
case 200200:
return "ZOO"
case 1:
case 42161:
case 8453:
return "ETH"
case 137:
return "MATIC"
case 43114:
return "AVAX"
default:
return "—"
}
}
const wrap: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 4 }
const originRow: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 2,
padding: "8px 0 12px",
borderBottom: "1px solid #1a1a1a",
marginBottom: 8,
}
const originLabel: React.CSSProperties = {
fontSize: 12,
color: "rgba(255,255,255,0.5)",
textTransform: "uppercase",
letterSpacing: 0.5,
}
const dapp: React.CSSProperties = { fontSize: 14, fontWeight: 500 }
const dappUrl: React.CSSProperties = { color: "rgba(255,255,255,0.5)", fontSize: 12 }
const summary: React.CSSProperties = { fontSize: 13, color: "rgba(255,255,255,0.7)" }
const field: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
gap: 12,
padding: "10px 0",
borderBottom: "1px solid #141414",
}
const fieldLabel: React.CSSProperties = {
fontSize: 12,
color: "rgba(255,255,255,0.5)",
}
const fieldValue: React.CSSProperties = { fontSize: 13, textAlign: "right" }
const fiatHint: React.CSSProperties = { color: "rgba(255,255,255,0.5)", marginLeft: 6 }
const monoStyle: React.CSSProperties = {
fontFamily: "ui-monospace, SFMono-Regular, monospace",
}
const decodeBox: React.CSSProperties = {
marginTop: 12,
padding: 12,
background: "#0a0a0a",
border: "1px solid #1a1a1a",
borderRadius: 8,
}
const decodeHead: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
}
const decodeTitle: React.CSSProperties = {
fontSize: 12,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: 0.5,
color: "rgba(255,255,255,0.5)",
margin: 0,
}
const lookupBtn: React.CSSProperties = {
fontSize: 11,
padding: "4px 8px",
background: "transparent",
color: "#fff",
border: "1px solid #2a2a2a",
borderRadius: 6,
cursor: "pointer",
}
const fnHeader: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
alignItems: "baseline",
gap: 8,
marginBottom: 8,
}
const fnName: React.CSSProperties = {
fontFamily: "ui-monospace, SFMono-Regular, monospace",
fontSize: 14,
fontWeight: 600,
color: "#fff",
}
const fnSig: React.CSSProperties = {
fontFamily: "ui-monospace, SFMono-Regular, monospace",
fontSize: 11,
color: "rgba(255,255,255,0.5)",
}
const badge: React.CSSProperties = {
fontSize: 10,
padding: "2px 6px",
background: "#1a1a1a",
borderRadius: 999,
color: "rgba(255,255,255,0.6)",
}
const badgeWarn: React.CSSProperties = {
fontSize: 10,
padding: "2px 6px",
background: "#3a2a00",
borderRadius: 999,
color: "#ffb84d",
}
const argsList: React.CSSProperties = { listStyle: "none", padding: 0, margin: 0 }
const argRow: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
gap: 12,
padding: "4px 0",
fontSize: 12,
fontFamily: "ui-monospace, SFMono-Regular, monospace",
}
const argName: React.CSSProperties = { color: "rgba(255,255,255,0.7)" }
const argType: React.CSSProperties = { color: "rgba(255,255,255,0.4)" }
const argValue: React.CSSProperties = {
color: "#fff",
textAlign: "right",
wordBreak: "break-all",
maxWidth: "60%",
}
const raw: React.CSSProperties = {
fontFamily: "ui-monospace, SFMono-Regular, monospace",
fontSize: 12,
color: "rgba(255,255,255,0.6)",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}
@@ -0,0 +1,45 @@
/**
* Signing-modal hook + imperative API.
*
* Why both?
* - The hook (`useSigningModal`) is for components that want to render a
* button and `await` user confirmation inline.
* - The static `signingModal` is for non-component callers (sagas, dApp
* connector RPC handlers) that can't use hooks.
*
* Both forms back onto the same global `useSigningStore` — there is exactly
* one queue, with a maximum depth of one in-flight request.
*
* Contract:
* const { approved } = await signingModal.open(tx)
* if (approved) await broadcaster.send(tx)
*/
import { useSigningStore, type UnsignedTx, type SigningResult } from "../../store/signing"
export interface SigningModalApi {
open(tx: UnsignedTx): Promise<SigningResult>
close(): void
confirm(): void
reject(): void
}
/**
* Imperative API. Importable from non-component code.
* await signingModal.open({ chainId, from, to, value, data, origin: "dapp" })
*/
export const signingModal: SigningModalApi = {
open: (tx) => useSigningStore.getState().open(tx),
close: () => useSigningStore.getState().cancel("closed"),
confirm: () => useSigningStore.getState().confirm(),
reject: () => useSigningStore.getState().reject(),
}
/** React hook variant — same surface, also re-renders on pending change. */
export function useSigningModal(): SigningModalApi & {
pending: ReturnType<typeof useSigningStore.getState>["pending"]
} {
const pending = useSigningStore((s) => s.pending)
return { ...signingModal, pending }
}
export type { UnsignedTx, SigningResult }
+135
View File
@@ -0,0 +1,135 @@
/**
* Settings slice — persisted user preferences.
*
* Shape:
* - networks: per-chain RPC overrides keyed by EIP-155 id
* - security: biometric toggle, auto-lock timeout (minutes; null = never)
* - locale: IETF language tag, "en" by default
* - theme: "system" | "dark" | "light"
* - currency: ISO 4217 code from a fixed allowlist
*
* Persistence boundary: zustand `persist` writes to localStorage. Nothing
* here is sensitive — it's UI prefs only. Auth state lives in `auth.ts`
* with its own much stricter partialize policy.
*
* Threat model: this store is read by the wallet UI only. Even so, RPC
* overrides influence which endpoint sees the user's queries; we never
* accept or store an HTTP (non-TLS) URL — `setNetworkRpc` rejects them.
*/
import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware"
export type Theme = "system" | "dark" | "light"
export const CURRENCY_CHOICES = ["USD", "EUR", "JPY", "GBP", "CHF", "CAD"] as const
export type Currency = (typeof CURRENCY_CHOICES)[number]
/**
* Auto-lock timeout choices (minutes). `null` = never auto-lock.
* The list is fixed so the slider in `Security.tsx` snaps cleanly.
*/
export const AUTO_LOCK_CHOICES: Array<number | null> = [1, 5, 15, 60, null]
export type AutoLockMin = (typeof AUTO_LOCK_CHOICES)[number]
export interface NetworkOverride {
/** User-supplied RPC URL. Must be `https://...`. Empty string is rejected. */
rpcOverride?: string
}
export interface SecurityPrefs {
/** Whether biometric (WebAuthn) unlock is enabled in addition to PIN. */
biometric: boolean
/** Auto-lock idle timeout in minutes; `null` means never auto-lock. */
autoLockMin: AutoLockMin
}
export interface SettingsState {
networks: Record<number, NetworkOverride>
security: SecurityPrefs
locale: string
theme: Theme
currency: Currency
setNetworkRpc: (chainId: number, url: string | undefined) => void
setBiometric: (on: boolean) => void
setAutoLockMin: (min: AutoLockMin) => void
setLocale: (locale: string) => void
setTheme: (t: Theme) => void
setCurrency: (c: Currency) => void
}
const INITIAL: Pick<
SettingsState,
"networks" | "security" | "locale" | "theme" | "currency"
> = {
networks: {},
security: { biometric: false, autoLockMin: 5 },
locale: "en",
theme: "system",
currency: "USD",
}
/** Reject URLs that aren't HTTPS — RPC overrides flow user funds, never plaintext. */
function isSafeRpcUrl(url: string): boolean {
if (!url) return false
try {
const u = new URL(url)
return u.protocol === "https:"
} catch {
return false
}
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
...INITIAL,
setNetworkRpc: (chainId, url) => {
const networks = { ...get().networks }
if (!url) {
delete networks[chainId]
} else if (isSafeRpcUrl(url)) {
networks[chainId] = { rpcOverride: url }
} else {
// Silently ignore unsafe input — UI shows the validation error.
return
}
set({ networks })
},
setBiometric: (on) =>
set((s) => ({ security: { ...s.security, biometric: on } })),
setAutoLockMin: (min) =>
set((s) => ({ security: { ...s.security, autoLockMin: min } })),
setLocale: (locale) => set({ locale }),
setTheme: (theme) => set({ theme }),
setCurrency: (currency) => set({ currency }),
}),
{
name: "lux-wallet-settings",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
networks: state.networks,
security: state.security,
locale: state.locale,
theme: state.theme,
currency: state.currency,
}),
},
),
)
/** Read an RPC override for a chain, or `undefined` if unset. */
export function getRpcOverride(chainId: number): string | undefined {
return useSettingsStore.getState().networks[chainId]?.rpcOverride
}
/** Validation helper exposed for the Networks screen UI. */
export function validateRpcUrl(url: string): string | null {
if (!url.trim()) return null // empty = clear override, not an error
if (!isSafeRpcUrl(url)) return "RPC URL must start with https://"
return null
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Signing slice — single-slot queue for the global signing modal.
*
* Any slice (Send/Swap/Bridge/dApps) calls `useSigningStore.getState().open(tx)`
* via the `useSigningModal()` ergonomic wrapper. The modal renders the
* pending tx, and the user's confirm/reject resolves the promise the
* caller is awaiting.
*
* Why one slot: the user's attention is the bottleneck. Two prompts at
* once is a phishing surface — a malicious dApp could try to slip a
* second tx behind a legitimate one. We refuse a second `open()` while
* one is pending.
*
* Threat model:
* - Modal is a confirmation surface. It MUST display canonical
* decoded calldata (lib/abi). Never trust the dApp's `summary` field.
* - Resolution is deterministic — `confirm/reject/cancel` all resolve
* the same promise. Callers receive `{approved: bool, reason?: string}`.
* - On tab unload, the pending request is rejected with reason "closed"
* so awaiting code unwinds cleanly.
*/
import { create } from "zustand"
export type TxOrigin = "send" | "swap" | "bridge" | "dapp"
export interface DAppCaller {
name: string
url: string
iconUrl?: string
}
/**
* Unsigned tx envelope shown to the user. We carry hex strings rather
* than bigints so the store remains JSON-serialisable for devtools.
*/
export interface UnsignedTx {
origin: TxOrigin
chainId: number
from: string
to: string
/** Hex-prefixed calldata, "0x" for plain transfers. */
data: string
/** Wei value as a decimal string, "0" for non-payable calls. */
value: string
/** Optional pre-estimated gas (decimal). Modal estimates if missing. */
gas?: string
/** Optional gas price / max fee per gas (decimal). */
gasPrice?: string
/** Optional nonce override. */
nonce?: number
/** Optional human-readable summary supplied by the calling slice. */
summary?: string
/** dApp metadata, present only when origin === "dapp". */
dapp?: DAppCaller
}
export interface SigningResult {
approved: boolean
/** Set when approved=false: "rejected" | "closed" | "timeout". */
reason?: "rejected" | "closed" | "timeout"
}
interface PendingEntry extends UnsignedTx {
/** Resolver from the caller's `await`. */
resolve: (r: SigningResult) => void
}
export interface SigningState {
pending: PendingEntry | null
open: (tx: UnsignedTx) => Promise<SigningResult>
confirm: () => void
reject: () => void
/** Generic close — used by ESC, backdrop click, beforeunload. */
cancel: (reason: SigningResult["reason"]) => void
}
export const useSigningStore = create<SigningState>((set, get) => ({
pending: null,
open: (tx) => {
const existing = get().pending
if (existing) {
// Refuse a second prompt — the existing one must resolve first.
return Promise.resolve<SigningResult>({
approved: false,
reason: "rejected",
})
}
return new Promise<SigningResult>((resolve) => {
set({ pending: { ...tx, resolve } })
})
},
confirm: () => {
const p = get().pending
if (!p) return
p.resolve({ approved: true })
set({ pending: null })
},
reject: () => {
const p = get().pending
if (!p) return
p.resolve({ approved: false, reason: "rejected" })
set({ pending: null })
},
cancel: (reason) => {
const p = get().pending
if (!p) return
p.resolve({ approved: false, reason })
set({ pending: null })
},
}))
/**
* On tab unload, reject any pending request so callers' awaits unwind.
* Without this, a navigation could leave the modal's promise dangling.
*/
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", () => {
useSigningStore.getState().cancel("closed")
})
}