mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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}>I’ve 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)" }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user