This commit is contained in:
2026-10-19 15:42:39 -04:00
parent 6d3f470922
commit 4b47386853
218 changed files with 25348 additions and 383 deletions
+136 -114
View File
@@ -1,132 +1,153 @@
import { useEffect, useRef, useState } from 'react';
import { ArrowDownLeft, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp,
Copy, Cpu, ExternalLink, Globe2, Layers3, LayoutDashboard, Leaf, LoaderCircle,
Pickaxe, Search, Shield, Sparkles, Square, Wallet as WalletIcon, X, Zap } from 'lucide-react';
import { claimMessage, ownerOf, rarity, transferMessage, type Block, type ChainState, type Challenge } from '../shared/protocol.ts';
Copy, Cpu, Download, ExternalLink, Globe2, Layers3, LayoutDashboard, LoaderCircle,
Pickaxe, Search, Shield, Sparkles, Wallet as WalletIcon, X } from 'lucide-react';
import { rarity } from '../shared/protocol.ts';
import { CONSENSUS, NETWORK, nftTransferMessage, testnetOwnerOf,
type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
import { getWallet, sign, type Wallet } from './wallet.ts';
import { Card, samples, imageCredit, type CardData } from './Card.tsx';
import { WalletPanel } from './WalletPanel.tsx';
import { useModalFocus } from './useModalFocus.ts';
import { ThemePicker } from './ThemePicker.tsx';
import './App.css';
import './testnet.css';
type Snapshot = ChainState & { challenge: Challenge };
type View = 'Discover' | 'My collection' | 'Block explorer';
const short = (s: string) => `${s.slice(0, 6)}…${s.slice(-4)}`;
async function request<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`/api/${path}`, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
if (!res.headers.get('content-type')?.includes('application/json')) throw new Error('The API is unavailable. Start both Wikideck services.');
const short = (s: string) => s ? `${s.slice(0, 6)}…${s.slice(-4)}` : '—';
const asCard = (block: TestnetBlock): CardData => ({ article: block.article, hash: block.powHash, block });
const message = (error: unknown) => error instanceof Error ? error.message : 'Request failed. Please try again.';
async function request<T>(path: string, body?: unknown, expectedStatus?: number): Promise<T> {
const res = await fetch(`/api/${path}`, { cache: 'no-store', signal: AbortSignal.timeout(15000),
...(body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) });
if (!res.headers.get('content-type')?.includes('application/json')) throw new Error('The testnet RPC is unavailable. Start the node and web services.');
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Request failed');
if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Request failed');
if (expectedStatus !== undefined && res.status !== expectedStatus) throw new Error('Unexpected testnet RPC response. Refresh before trying again.');
return data;
}
// Only canonical, mined transfers determine ownership and the next signature sequence.
function transferProblem(state: TestnetSnapshot, card: CardData, address: string): string {
const mint = card.block;
const block = mint && state.blocks.find(b => b.tokenId === mint.tokenId && b.hash === mint.hash);
if (!block) return 'This NFT is no longer on the canonical chain. Refresh your collection.';
if (testnetOwnerOf(state, block.tokenId) !== address) return 'This wallet is not the confirmed owner.';
if (state.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity)
return 'Immature NFT: transfers require two subsequent blocks at the next inclusion height.';
if (state.pendingTransfers.some(t => t.cardHash === block.tokenId)) return 'A transfer for this NFT is already pending mining.';
return '';
}
export default function App() {
const [view, setView] = useState<View>('Discover');
const [wallet, setWallet] = useState<Wallet>();
const [snapshot, setSnapshot] = useState<Snapshot>();
const [snapshot, setSnapshot] = useState<TestnetSnapshot>();
const [rpcAvailable, setRpcAvailable] = useState(false);
const [error, setError] = useState('');
const [toast, setToast] = useState('');
const [dialog, setDialog] = useState<'guide' | 'wallet' | 'transfer' | null>(null);
const [selected, setSelected] = useState<CardData>();
const [query, setQuery] = useState('');
const [category, setCategory] = useState('All cards');
const [mining, setMining] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [consent, setConsent] = useState(false);
const [duty, setDuty] = useState(0.5);
const [progress, setProgress] = useState({ attempts: 0, rate: 0, hash: '' });
const [recipient, setRecipient] = useState('');
const [transferBusy, setTransferBusy] = useState(false);
useModalFocus(Boolean(dialog || selected));
const worker = useRef<Worker | null>(null);
const runId = useRef(0);
const activeSeed = useRef('');
const [transferError, setTransferError] = useState('');
const transferInFlight = useRef(false);
const refreshVersion = useRef(0);
const minerPanel = useRef<HTMLElement>(null);
const stop = () => { worker.current?.terminate(); worker.current = null; activeSeed.current = ''; runId.current++; setMining(false); };
useModalFocus(Boolean(dialog || selected));
const refresh = async () => {
const data = await request<Snapshot>('state');
setSnapshot(data); setError('');
if (activeSeed.current && activeSeed.current !== data.challenge.seed) {
stop(); setToast('A new block was mined. Start again to discover the next article.');
const version = ++refreshVersion.current;
try {
const data = await request<TestnetSnapshot>('state');
if (data.network !== NETWORK || data.algorithm !== 'RandomX' || typeof data.chainId !== 'string' ||
!Array.isArray(data.blocks) || !Array.isArray(data.transfers) || !Array.isArray(data.pendingTransfers) || !data.nextArticle)
throw new Error('Incompatible node. This interface requires the Wikideck RandomX testnet API.');
if (version === refreshVersion.current) { setSnapshot(data); setRpcAvailable(true); setError(''); }
return data;
} catch (e) {
if (version === refreshVersion.current) { setRpcAvailable(false); setError(message(e)); }
throw e;
}
return data;
};
useEffect(() => {
getWallet().then(setWallet).catch(e => setError(e.message));
refresh().catch(e => setError(e.message));
const timer = setInterval(() => refresh().catch(e => setError(e.message)), 8000);
const visibility = () => { if (document.hidden) stop(); };
document.addEventListener('visibilitychange', visibility);
return () => { clearInterval(timer); worker.current?.terminate(); document.removeEventListener('visibilitychange', visibility); };
let active = true;
getWallet().then(value => { if (active) setWallet(value); }).catch(e => { if (active) setError(message(e)); });
void refresh().catch(() => {});
const timer = setInterval(() => { void refresh().catch(() => {}); }, 8000);
return () => { active = false; clearInterval(timer); refreshVersion.current++; };
}, []);
useEffect(() => { if (toast) { const t = setTimeout(() => setToast(''), 6000); return () => clearTimeout(t); } }, [toast]);
useEffect(() => {
const escape = (e: KeyboardEvent) => { if (e.key === 'Escape' && !transferBusy) { setDialog(null); setSelected(undefined); } };
if (toast) { const timer = setTimeout(() => setToast(''), 8000); return () => clearTimeout(timer); }
}, [toast]);
useEffect(() => {
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !transferInFlight.current) { setDialog(null); setSelected(undefined); setTransferError(''); }
};
document.addEventListener('keydown', escape);
return () => document.removeEventListener('keydown', escape);
}, [transferBusy]);
const start = async () => {
if (!wallet || !consent || mining || submitting) return;
setError('');
stop();
const id = runId.current;
setMining(true);
setProgress({ attempts: 0, rate: 0, hash: '' });
try {
const data = await refresh();
if (id !== runId.current || document.hidden) { stop(); return; }
activeSeed.current = data.challenge.seed;
const w = new Worker(new URL('./miner.worker.ts', import.meta.url), { type: 'module' });
worker.current = w;
w.onerror = () => { stop(); setError('Mining worker could not start. Check WebAssembly support.'); };
w.onmessage = async ({ data: event }) => {
if (id !== runId.current) return;
if (event.type === 'progress') setProgress(event);
if (event.type === 'error') { stop(); setError(event.message); }
if (event.type === 'found') {
stop(); setSubmitting(true);
try {
const signature = await sign(wallet, claimMessage(data.challenge, wallet.address, event.nonce, event.hash));
const { block } = await request<{ block: Block }>('mine', { owner: wallet.address, publicKey: wallet.publicKey,
signature, nonce: event.nonce, hash: event.hash, seed: data.challenge.seed });
await refresh();
setSelected({ article: block.article, hash: block.hash, block });
setToast(`Block #${block.height} verified. ${block.article.title} is yours!`);
} catch (e) { setError((e as Error).message); }
finally { setSubmitting(false); }
}
};
w.postMessage({ challenge: data.challenge, owner: wallet.address, duty });
} catch (e) { stop(); setError((e as Error).message); }
};
}, []);
const transfer = async () => {
if (!wallet || !selected?.block || !snapshot) return;
setTransferBusy(true);
if (transferInFlight.current || !wallet || !selected?.block || !snapshot) return;
transferInFlight.current = true; setTransferBusy(true); setTransferError('');
try {
const t = { cardHash: selected.hash, from: wallet.address, to: recipient.trim().toLowerCase(),
sequence: snapshot.transfers.filter(t => t.cardHash === selected.hash).length };
await request('transfer', { ...t, publicKey: wallet.publicKey, signature: await sign(wallet, transferMessage(t)) });
await refresh(); setDialog(null); setSelected(undefined); setRecipient(''); setToast('Card transferred. Ownership has been updated.');
} catch (e) { setError((e as Error).message); }
finally { setTransferBusy(false); }
const state = await refresh();
if (state.chainId !== snapshot.chainId) throw new Error('The node chain ID changed. Refresh and review this NFT before signing.');
const problem = transferProblem(state, selected, wallet.address);
if (problem) throw new Error(problem);
const to = recipient.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/.test(to) || to === wallet.address) throw new Error('Enter another wallet’s 64-character receive address.');
const tokenId = selected.block.tokenId;
const t = { network: NETWORK, chainId: state.chainId, cardHash: tokenId,
from: wallet.address, to, sequence: state.transfers.filter(tx => tx.cardHash === tokenId).length };
const tx = { ...t, publicKey: wallet.publicKey, signature: await sign(wallet, nftTransferMessage(t)) };
await request('transfer', tx, 202);
// A 202 is a mempool acknowledgement, NOT a change of confirmed ownership.
refreshVersion.current++;
setSnapshot(current => current && current.chainId === state.chainId ? { ...current,
pendingTransfers: [...current.pendingTransfers.filter(p => p.cardHash !== tx.cardHash), tx] } : current);
setDialog(null); setRecipient('');
setToast('Transfer pending. You remain the confirmed owner until it is mined into the canonical chain.');
void refresh().catch(() => {});
} catch (e) { setTransferError(message(e)); }
finally { transferInFlight.current = false; setTransferBusy(false); }
};
const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address) ?? [];
const visibleCards = view === 'My collection' ? owned.map(asCard)
: snapshot?.blocks.length ? [...snapshot.blocks].reverse().slice(0, 12).map(asCard) : samples;
const filtered = visibleCards.filter(c => (category === 'All cards' || c.article.category === category) &&
`${c.article.title} ${c.article.extract}`.toLowerCase().includes(query.toLowerCase()));
const selectedMint = selected?.block;
const selectedBlock = selectedMint && snapshot?.blocks.find(b => b.tokenId === selectedMint.tokenId && b.hash === selectedMint.hash);
const confirmations = selectedBlock && snapshot ? snapshot.blocks.length - selectedBlock.height + 1 : 0;
const selectedOwner = selectedBlock && snapshot ? testnetOwnerOf(snapshot, selectedBlock.tokenId) : undefined;
const pending = !!selectedBlock && !!snapshot?.pendingTransfers.some(t => t.cardHash === selectedBlock.tokenId);
const transferDisabledReason = selected && wallet && snapshot ? transferProblem(snapshot, selected, wallet.address) : 'Wallet and node state are required.';
const peerLabel = !rpcAvailable ? 'Peers unknown' : snapshot?.peers ? `${snapshot.peers} peers` : 'Isolated node';
const nativeCommand = `npm run miner -- --wallet <backup.json> --consent --node ${window.location.origin}`;
const close = () => { if (!transferInFlight.current) { setDialog(null); setSelected(undefined); setTransferError(''); } };
const openWallet = () => { setSelected(undefined); setDialog('wallet'); };
const goMine = () => {
setView('Discover');
setTimeout(() => minerPanel.current?.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' }), 50);
};
const owned = snapshot?.blocks.filter(b => ownerOf(snapshot, b.hash) === wallet?.address) ?? [];
const visibleCards: CardData[] = view === 'My collection'
? owned.map(block => ({ article: block.article, hash: block.hash, block }))
: snapshot?.blocks.length ? [...snapshot.blocks].reverse().slice(0, 12).map(block => ({ article: block.article, hash: block.hash, block })) : samples;
const filtered = visibleCards.filter(c => (category === 'All cards' || c.article.category === category) && `${c.article.title} ${c.article.extract}`.toLowerCase().includes(query.toLowerCase()));
const goMine = () => { setView('Discover'); setTimeout(() => minerPanel.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 50); };
const copyAddress = async () => {
if (!wallet) return;
try { await navigator.clipboard.writeText(wallet.address); setToast('Wallet address copied.'); }
catch { setDialog('wallet'); setToast('Copy your address from the wallet panel.'); }
catch { setToast('Clipboard unavailable. Select and copy your receive address below.'); }
};
const updateWallet = (restored: Wallet) => {
setWallet(restored); setSelected(undefined); setRecipient(''); setTransferError('');
setToast('Wallet restored locally. Refreshing confirmed NFT ownership.');
void refresh().catch(() => {});
};
const retry = async () => {
setError('');
try { setWallet(await getWallet()); await refresh(); }
catch (e) { setError((e as Error).message); }
catch (e) { setError(message(e)); }
};
const close = () => { if (!transferBusy) { setDialog(null); setSelected(undefined); } };
return <div className="app-shell">
<aside className="sidebar">
@@ -134,52 +155,53 @@ export default function App() {
<div className="sidebar-label">YOUR NEXT DISCOVERY</div>
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer'] as View[]).map((item, i) => {
const Icon = [LayoutDashboard, Layers3, Globe2][i];
return <button key={item} className={`nav-item ${view === item ? 'active' : ''}`} onClick={() => { setView(item); setQuery(''); setCategory('All cards'); }}><Icon size={18} />{item}{item === 'My collection' && <span className="nav-count">{owned.length}</span>}</button>;
return <button key={item} aria-label={item} aria-current={view === item ? 'page' : undefined} className={`nav-item ${view === item ? 'active' : ''}`} onClick={() => { setView(item); setQuery(''); setCategory('All cards'); }}><Icon size={18} />{item}{item === 'My collection' && <span className="nav-count">{owned.length}</span>}</button>;
})}</nav>
<div className="sidebar-note"><span className="tiny-spark">✦</span><h3>A little curiosity.<br />An entire universe.</h3><p>Turn the world’s knowledge into something you can collect.</p><button onClick={() => setDialog('guide')}>How it works <ArrowUpRight size={15} /></button></div>
<div className="sidebar-bottom"><span className="network-dot" /><span>Local network</span><span className="prototype">ALPHA</span></div>
<div className="sidebar-note"><span className="tiny-spark">✦</span><h3>A little curiosity.<br />An independent chain.</h3><p>Collect knowledge as native NFTs on a sovereign RandomX testnet.</p><button onClick={() => setDialog('guide')}>How it works <ArrowUpRight size={15} /></button></div>
<div className="sidebar-bottom"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} /><span>{peerLabel}</span><span className="prototype">TESTNET</span></div>
<button className="help-link" onClick={() => setDialog('guide')}><CircleHelp size={16} /> The field guide <ArrowUpRight size={14} /></button>
</aside>
<div className="main-shell">
<header className="topbar"><div className="breadcrumbs">Explore <ChevronRight size={13} /><span>{view}</span></div><div className="top-actions"><ThemePicker /><span className="network-pill"><span className={`network-dot ${!snapshot ? 'disconnected' : ''}`} />{snapshot ? 'Network online' : 'Connecting'}</span><button className="wallet-button" onClick={() => setDialog('wallet')}><span className="wallet-avatar"><WalletIcon size={14} /></span>{wallet ? short(wallet.address) : 'Creating wallet'}<ChevronRight size={14} /></button></div></header>
<header className="topbar"><div className="breadcrumbs">Explore <ChevronRight size={13} /><span>{view}</span></div><div className="top-actions"><span className="testnet-brand">RandomX <b>TESTNET</b></span><ThemePicker /><span className="network-pill"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} />{peerLabel}</span><button className="wallet-button" onClick={openWallet}><span className="wallet-avatar"><WalletIcon size={14} /></span>{wallet ? short(wallet.address) : 'Open wallet'}<ChevronRight size={14} /></button></div></header>
<main>
<div className="testnet-status" role="status"><span><span className={`network-dot ${rpcAvailable ? '' : 'disconnected'}`} /> RPC {rpcAvailable ? 'available' : 'unavailable'}</span><span>{peerLabel}</span><span>Unbootstrapped testnet · no public mainnet</span>{snapshot && !rpcAvailable && <strong>Showing last known state</strong>}</div>
{error && <div className="error-banner" role="alert"><CircleHelp size={17} /><span>{error}</span><button onClick={retry}>Retry</button><button aria-label="Dismiss error" onClick={() => setError('')}><X size={16} /></button></div>}
{view === 'Discover' && <>
<section className="hero">
<div className="hero-copy"><span className="eyebrow"><span /> THE KNOWLEDGE IS REAL. SO ARE THE CARDS.</span><h1>Mine knowledge.<br />Collect the <span>extraordinary.</span></h1><p>One encyclopedia. Endless discoveries. Put your curiosity to work and turn Wikipedia into your next great collection.</p><div className="hero-actions"><button className="button dark" onClick={goMine}><Pickaxe size={17} /> Start your discovery <ArrowUpRight size={17} /></button><button className="text-button" onClick={() => setDialog('guide')}>How it works <ArrowRight size={16} /></button></div><div className="hero-foot"><span><Check size={13} /> No purchase needed</span><span><Check size={13} /> Mined in your browser</span></div></div>
<div className="hero-visual"><div className="orbit orbit-one" /><div className="orbit orbit-two" /><span className="orbit-spark spark-one">✳</span><span className="orbit-spark spark-two">+</span><div className="floating-label label-top"><span className="network-dot" /> Powered by curiosity</div><div className="hero-card back-card"><Card card={samples[2]} onClick={() => setSelected(samples[2])} /></div><div className="hero-card front-card"><Card card={samples[0]} onClick={() => setSelected(samples[0])} /></div><div className="floating-label label-bottom"><Sparkles size={15} /> A world worth collecting <ArrowUpRight size={13} /></div></div>
<div className="hero-copy"><span className="eyebrow"><span /> KNOWLEDGE, COLLECTED ON ITS OWN CHAIN.</span><h1>Mine knowledge.<br />Collect the <span>extraordinary.</span></h1><p>Four curated article snapshots. A chain of new discoveries. Mine native NFTs with an opt-in RandomX miner, and keep the keys to your collection.</p><div className="hero-actions"><button className="button dark" onClick={goMine}><Pickaxe size={17} /> Explore native mining <ArrowUpRight size={17} /></button><button className="text-button" onClick={() => setDialog('guide')}>How it works <ArrowRight size={16} /></button></div><div className="hero-foot"><span><Check size={13} /> Independent testnet</span><span><Check size={13} /> No browser mining</span></div></div>
<div className="hero-visual"><div className="orbit orbit-one" /><div className="orbit orbit-two" /><span className="orbit-spark spark-one">✳</span><span className="orbit-spark spark-two">+</span><div className="floating-label label-top"><Sparkles size={12} /> Preview cards · not owned</div><div className="hero-card back-card"><Card card={samples[2]} onClick={() => setSelected(samples[2])} /></div><div className="hero-card front-card"><Card card={samples[0]} onClick={() => setSelected(samples[0])} /></div><div className="floating-label label-bottom"><Sparkles size={15} /> A world worth collecting <ArrowUpRight size={13} /></div></div>
</section>
<section className="metrics" aria-label="Network statistics">
<div><span className="metric-icon"><Layers3 size={19} /></span><div><span>Cards discovered</span><strong>{snapshot?.blocks.length.toLocaleString() ?? '—'}<small>on this network</small></strong></div></div>
<div><span className="metric-icon"><Globe2 size={19} /></span><div><span>Next block</span><strong>#{snapshot?.challenge.height.toString().padStart(4, '0') ?? '—'}<small>a new discovery</small></strong></div></div>
<div><span className="metric-icon"><WalletIcon size={19} /></span><div><span>Your collection</span><strong>{owned.length}<small>{owned.length === 1 ? 'unique story' : 'unique stories'}</small></strong></div></div>
<div><span className="metric-icon leaf"><Leaf size={19} /></span><div><span>Proof of curiosity</span><strong className="algorithm">Argon2id<small>memory-hard · 16 MiB</small></strong></div></div>
<section className="metrics" aria-label="Canonical chain statistics">
<div><span className="metric-icon"><Layers3 size={19} /></span><div><span>Native NFTs minted</span><strong>{snapshot?.blocks.length.toLocaleString() ?? '—'}<small>canonical chain</small></strong></div></div>
<div><span className="metric-icon"><Globe2 size={19} /></span><div><span>Block height</span><strong>{snapshot ? `#${snapshot.blocks.at(-1)?.height ?? 0}` : '—'}<small>current tip</small></strong></div></div>
<div><span className="metric-icon"><WalletIcon size={19} /></span><div><span>Confirmed ownership</span><strong>{wallet && snapshot ? owned.length : '—'}<small>in your wallet</small></strong></div></div>
<div><span className="metric-icon"><Cpu size={19} /></span><div><span>Proof of work</span><strong className="algorithm">{snapshot?.algorithm ?? 'RandomX'}<small>native CPU mining</small></strong></div></div>
</section>
<section className="mining-panel" ref={minerPanel}>
<div className="mining-description"><div className="section-eyebrow"><span className={mining ? 'network-dot' : 'idle-dot'} /> {mining ? 'YOUR MINER IS RUNNING' : submitting ? 'VERIFYING YOUR DISCOVERY' : 'YOUR NEXT CARD STARTS HERE'}</div><h2>{mining ? 'A little work. A new world.' : submitting ? 'Making it official.' : 'Ready to uncover something?'}</h2><p>{mining ? 'Your browser is solving a memory-hard puzzle. Every attempt is another chance to discover.' : 'Solve a block in your browser. Get a one-of-a-kind card from a real Wikipedia article.'}</p><div className="mining-meta"><span><Cpu size={13} /> One local worker</span><span><Shield size={13} /> Server-verified proof</span><span><Zap size={13} /> No gas fees</span></div></div>
<div className="miner-controls"><div className="miner-display"><div><span>{mining ? 'HASH RATE' : 'NEXT ARTICLE'}</span><strong>{mining ? `${progress.rate.toFixed(1)} H/s` : snapshot?.challenge.article.title ?? 'Finding an article…'}</strong></div><div className={`signal-bars ${mining ? 'running' : ''}`}>{Array.from({ length: 18 }, (_, i) => <i key={i} style={{ height: `${10 + ((i * 13 + 7) % 23)}px`, animationDelay: `${i * 0.07}s` }} />)}</div></div>
{(mining || submitting) ? <><div className="hash-line">{submitting ? 'Proof found. Verifying and saving…' : `${progress.attempts} attempts · ${progress.hash.slice(0, 22) || 'Initializing WebAssembly'}…`}</div><button className="button mining-stop" disabled={submitting} onClick={stop}>{submitting ? <LoaderCircle size={16} className="spin" /> : <Square size={14} />}{submitting ? 'Verifying block' : 'Stop mining'}</button></> : <><div className="intensity"><label htmlFor="duty">Worker duty cycle</label><select id="duty" value={duty} onChange={e => setDuty(Number(e.target.value))}><option value={0.25}>Gentle · 25%</option><option value={0.5}>Balanced · 50%</option><option value={0.85}>Focused · 85%</option></select></div><label className="consent"><input type="checkbox" checked={consent} onChange={e => setConsent(e.target.checked)} />I agree to use this device’s CPU and battery.</label><button className="button lime" onClick={start} disabled={!consent || !wallet || !snapshot}><Pickaxe size={17} /> Start mining <ArrowRight size={16} /></button></>}
<small className="miner-footnote">Always opt-in. Stops when you switch tabs. No background mining.</small>
</div>
<section className="mining-panel native-mining" ref={minerPanel}>
<div className="mining-description"><div className="section-eyebrow"><span className="idle-dot" /> EXPLICIT OPT-IN · OUTSIDE YOUR BROWSER</div><h2>Native RandomX mining</h2><p>This page never runs mining code or launches a native program. Build the trusted native binary, download an encrypted wallet backup, then start the miner yourself in an interactive terminal.</p><div className="mining-meta"><span><Cpu size={13} /> One native worker</span><span><Shield size={13} /> Independent validating peers</span></div><div className="native-memory"><strong>Light · default</strong><span>256 MiB cache + scratchpad and overhead.</span><strong>Fast · explicit --fast</strong><span>~2 GiB dataset plus cache; budget over 2.3 GiB per process. Higher CPU, energy and memory use. RandomX is not a promise of ASIC-proof mining.</span></div></div>
<div className="miner-controls"><div className="miner-display"><div><span>NEXT CONSENSUS ARTICLE</span><strong>{snapshot?.nextArticle.title ?? 'Waiting for testnet RPC…'}</strong></div></div><p className="muted">Fixed four-article snapshot catalog, not a live random Wikipedia feed.</p><ol className="native-steps"><li>Build <code>wikideck-randomx</code> using the repository’s native build instructions.</li><li>Save your encrypted backup and keep its password separate.</li><li>Replace <code>&lt;backup.json&gt;</code> with your local file path (quote paths with spaces), then run:</li></ol><pre className="native-command"><code>{nativeCommand}</code></pre><p className="miner-footnote">Node: {window.location.origin}. Same-origin RPC, including the development proxy. Use HTTPS for remote nodes. Password entry stays local; never put it in the command. Defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.</p><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the testnet field guide <ArrowRight size={14} /></button></div>
</section>
</>}
{view !== 'Block explorer' ? <section className="discover-section">
<div className="section-heading"><div><div className="section-eyebrow">{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}</div><h2>{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}<span className="count-badge">{visibleCards.length}</span></h2><p>{view === 'My collection' ? 'Every card has a story. These ones belong to you.' : snapshot?.blocks.length ? 'Real cards recently mined on your local network.' : 'A taste of what’s out there. Preview cards are examples, not owned assets.'}</p></div>{view === 'Discover' && <button className="text-button" onClick={() => setView('My collection')}>My collection <ArrowRight size={16} /></button>}</div>
<div className="section-heading"><div><div className="section-eyebrow">{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}</div><h2>{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}<span className="count-badge">{visibleCards.length}</span></h2><p>{view === 'My collection' ? 'Confirmed native NFT ownership. Pending transfers stay here until mined.' : snapshot?.blocks.length ? 'Recent native NFTs on this node’s canonical testnet chain.' : 'Preview examples from the fixed catalog. These are not owned NFTs.'}</p></div>{view === 'Discover' && <button className="text-button" onClick={() => setView('My collection')}>My collection <ArrowRight size={16} /></button>}</div>
<div className="filter-bar"><div className="category-tabs">{['All cards', 'Nature', 'Space', 'Science', 'People', 'History', 'Culture'].map(c => <button key={c} className={category === c ? 'selected' : ''} onClick={() => setCategory(c)}>{c}</button>)}</div><label className="search-input"><Search size={15} /><input aria-label="Search cards" placeholder="Find a discovery…" value={query} onChange={e => setQuery(e.target.value)} /></label></div>
{filtered.length ? <div className="card-grid">{filtered.map(c => <div className="card-wrap" key={c.hash}><Card card={c} onClick={() => setSelected(c)} /><div className="card-list-caption"><span>{c.block ? `Block #${c.block.height}` : 'Example discovery'}</span><span className="caption-dot" />{c.block ? short(ownerOf(snapshot!, c.hash) ?? '') : 'Not yet mined'}</div></div>)}</div> : <div className="empty-state"><Layers3 size={38} /><h3>{query || category !== 'All cards' ? 'No discoveries here. Yet.' : 'Your first discovery is waiting.'}</h3><p>{query || category !== 'All cards' ? 'Try another search or explore a different category.' : 'Mine your first block to turn a Wikipedia article into a card you own.'}</p><button className="button dark" onClick={goMine}><Pickaxe size={16} /> Discover your first card</button></div>}
</section> : <section className="explorer-section"><div className="section-eyebrow">FOLLOW THE KNOWLEDGE</div><div className="section-heading"><div><h1>Block explorer<span className="count-badge">{snapshot?.blocks.length ?? 0}</span></h1><p>Every article. Every proof. Independently verifiable.</p></div><a className="button outline" href="/api/chain" target="_blank" rel="noreferrer">View chain JSON <ExternalLink size={15} /></a></div><div className="explorer-note"><Shield size={18} /><p>This is a local, single-coordinator proof-of-work chain. It is not a decentralized mainnet. Transfers are signed ownership records, not mined blocks.</p></div>
{snapshot?.blocks.length ? <div className="table-scroll"><table><thead><tr><th>Block</th><th>Discovery</th><th>Proof hash</th><th>Current owner</th><th>Rarity</th></tr></thead><tbody>{[...snapshot.blocks].reverse().map(b => <tr key={b.hash} onClick={() => setSelected({ article: b.article, hash: b.hash, block: b })}><td>#{b.height.toString().padStart(4, '0')}</td><td><strong>{b.article.title}</strong><small>Wikipedia #{b.article.pageId}</small></td><td className="mono">{short(b.hash)}</td><td className="mono">{short(ownerOf(snapshot, b.hash) ?? '')}</td><td><span className={`rarity-tag ${rarity(b.hash).toLowerCase()}`}>{rarity(b.hash)}</span></td></tr>)}</tbody></table></div> : <div className="empty-state"><Globe2 size={40} /><h3>A new chain. A blank page.</h3><p>Be the first to mine a block and make a discovery.</p><button className="button dark" onClick={goMine}>Mine the first block <ArrowRight size={16} /></button></div>}
<h2 className="transfer-title">Ownership journal</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((t, i) => <div key={i}><ArrowDownLeft size={18} /><span>{short(t.cardHash)}</span><span>{short(t.from)} → {short(t.to)}</span><span>Transfer #{t.sequence + 1}</span><Check size={14} /></div>)}</div> : <p className="muted">No transfers yet. Open a card you own to send it to another wallet.</p>}
{filtered.length ? <div className="card-grid">{filtered.map(c => <div className="card-wrap" key={c.block?.tokenId ?? c.hash}><Card card={c} onClick={() => { setSelected(c); setTransferError(''); }} /><div className="card-list-caption"><span>{c.block ? `Block #${c.block.height}` : 'Example discovery'}</span><span className="caption-dot" />{c.block && snapshot ? short(testnetOwnerOf(snapshot, c.block.tokenId) ?? '') : 'Not owned'}</div>{c.block && snapshot?.pendingTransfers.some(t => t.cardHash === c.block!.tokenId) && <span className="pending-label">Transfer pending mining</span>}</div>)}</div> : <div className="empty-state"><Layers3 size={38} /><h3>{query || category !== 'All cards' ? 'No discoveries here. Yet.' : 'Your first discovery is waiting.'}</h3><p>{query || category !== 'All cards' ? 'Try another search or explore a different category.' : 'Use the native miner or receive a mined transfer on this chain. Restore a backup to access an existing wallet.'}</p><button className="button dark" onClick={goMine}><Pickaxe size={16} /> Explore native mining</button></div>}
</section> : <section className="explorer-section"><div className="section-eyebrow">FOLLOW THE CANONICAL CHAIN</div><div className="section-heading"><div><h1>Block explorer<span className="count-badge">{snapshot?.blocks.length ?? 0}</span></h1><p>Native NFT mints, RandomX proofs, and mined ownership transfers.</p></div><a className="button outline" href="/api/chain" target="_blank" rel="noreferrer">View full chain JSON <ExternalLink size={15} /></a></div>
<div className="explorer-note"><Shield size={18} /><p>Sovereign testnet with independently validating peers and cumulative-work chain selection. This network is unbootstrapped, not a public mainnet. Zero peers means an isolated node, not a connected network. Canonical ownership can change during a reorganization.</p></div>
<dl className="chain-summary"><div><dt>Cumulative work</dt><dd>{snapshot?.cumulativeWork ?? '—'}</dd></div><div><dt>Peer count</dt><dd>{rpcAvailable ? snapshot?.peers ?? '—' : 'Unknown'} · {peerLabel}</dd></div><div><dt>Chain ID · pinned genesis</dt><dd>{snapshot?.chainId ?? '—'}</dd></div><div><dt>Network</dt><dd>{NETWORK}</dd></div><div><dt>Tip block ID</dt><dd>{snapshot?.tip ?? '—'}</dd></div><div><dt>Mint maturity</dt><dd>{snapshot?.maturity ?? CONSENSUS.coinbaseMaturity} subsequent blocks at inclusion height</dd></div></dl>
{snapshot?.blocks.length ? <div className="table-scroll"><table><thead><tr><th>Block / confirmations</th><th>Discovery</th><th>Block ID / NFT token ID</th><th>Confirmed owner</th><th>RandomX proof / rarity</th></tr></thead><tbody>{[...snapshot.blocks].reverse().map(b => <tr key={b.hash}><td><button className="explorer-card-link" onClick={() => setSelected(asCard(b))}>#{b.height.toString().padStart(4, '0')}</button><small>{snapshot.blocks.length - b.height + 1} confirmations</small></td><td><strong>{b.article.title}</strong><small>Wikipedia #{b.article.pageId}</small></td><td className="mono"><span title={b.hash}>Block {short(b.hash)}</span><a className="nft-link" href={`/api/nfts/${encodeURIComponent(b.tokenId)}`} target="_blank" rel="noreferrer" title={b.tokenId}>NFT {short(b.tokenId)} <ExternalLink size={11} /></a></td><td className="mono" title={testnetOwnerOf(snapshot, b.tokenId)}>{short(testnetOwnerOf(snapshot, b.tokenId) ?? '')}</td><td><span className="proof-caption mono" title={b.powHash}>{short(b.powHash)}</span><span className={`rarity-tag ${rarity(b.powHash).toLowerCase()}`}>{rarity(b.powHash)}</span></td></tr>)}</tbody></table></div> : <div className="empty-state"><Globe2 size={40} /><h3>A new chain. A blank page.</h3><p>No canonical NFT blocks loaded. Native mining is an explicit, separate step.</p><button className="button dark" onClick={goMine}>Explore native mining <ArrowRight size={16} /></button></div>}
<h2 className="transfer-title">Mined ownership transfers</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((t, i) => <div key={`${t.cardHash}-${t.sequence}`}><ArrowDownLeft size={18} /><a href={`/api/nfts/${encodeURIComponent(t.cardHash)}`} target="_blank" rel="noreferrer" title={t.cardHash}>{short(t.cardHash)}</a><span>{short(t.from)} → {short(t.to)}</span><span>Sequence {t.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(tx => tx.signature === t.signature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined transfer ${i + 1}`} /></div>)}</div> : <p className="muted">No mined transfers. Ownership changes only when a transfer is included in a canonical block.</p>}
<h2 className="transfer-title">Pending transfers <span className="count-badge">{snapshot?.pendingTransfers.length ?? 0}</span></h2><p className="muted">Mempool only · not confirmed ownership. Pending transactions may be dropped or invalidated by a reorganization.</p><div className="journal">{snapshot?.pendingTransfers.map(t => <div key={`${t.cardHash}-${t.sequence}`}><LoaderCircle size={16} /><span title={t.cardHash}>{short(t.cardHash)}</span><span>{short(t.from)} → {short(t.to)}</span><span className="pending-label">Awaiting mining</span></div>)}</div>
</section>}
<footer><a href="https://en.wikipedia.org" target="_blank" rel="noreferrer"><BookOpen size={16} /> Built on the shoulders of Wikipedia.</a><span>Curiosity is the currency.<span className="footer-star">✦</span></span><p>Independent experiment, not affiliated with Wikimedia. Live text: <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noreferrer">CC BY-SA 4.0</a>. Image credits on each card. No monetary value.</p></footer>
<footer><a href="https://en.wikipedia.org" target="_blank" rel="noreferrer"><BookOpen size={16} /> Built on the shoulders of Wikipedia.</a><span>Knowledge, not financial promises.<span className="footer-star">✦</span></span><p>Independent experiment, not affiliated with Wikimedia. Fixed snapshot catalog; attribution and image licenses on each card. Native NFTs are not ERC-721 tokens, fiat, or rights to article content. No promised monetary value.</p></footer>
</main>
</div>
{toast && <div className="toast" role="status"><span><Check size={16} /></span>{toast}<button onClick={() => setToast('')} aria-label="Dismiss notification"><X size={14} /></button></div>}
{(dialog || selected) && <div className="modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}><section className={`modal ${selected && !dialog ? 'card-modal' : ''}`} role="dialog" aria-modal="true" aria-label={dialog === 'wallet' ? 'Local wallet' : dialog === 'transfer' ? 'Transfer card' : dialog === 'guide' ? 'How Wikideck works' : 'Card details'}><button className="modal-close" aria-label="Close dialog" onClick={close} disabled={transferBusy}><X size={20} /></button>
{dialog === 'guide' ? <><span className="modal-icon"><Sparkles size={24} /></span><div className="section-eyebrow">THE FIELD GUIDE</div><h2>A universe in every card.</h2><div className="guide-steps"><div><span>01</span><div><h3>Put curiosity to work.</h3><p>Opt in to one browser worker running memory-hard Argon2id in WebAssembly. It uses 16 MiB per hash, two passes, and a low demo difficulty.</p></div></div><div><span>02</span><div><h3>Discover a little of everything.</h3><p>Each block commits to a Wikipedia page ID, revision, and text snapshot. The server checks your proof and P-256 wallet signature before awarding a card.</p></div></div><div><span>03</span><div><h3>Make it your collection.</h3><p>Rarity and game stats come from the mined hash, not scientific importance. Send cards with signed transfers. There is no combat mode or real-money marketplace yet.</p></div></div></div><div className="info-box"><strong>An honest experiment.</strong><p>Argon2id is memory-hard, not Monero’s RandomX and not proven ASIC-proof. This local coordinator is not a decentralized blockchain. Cards are not public-chain NFTs and have no promised monetary value. Mining consumes energy and may warm your device. On Wikipedia outages, clearly labeled offline starter articles are used.</p></div><button className="button dark full" onClick={() => { close(); goMine(); }}>Let’s discover something <ArrowRight size={16} /></button></>
: dialog === 'wallet' ? <><span className="modal-icon"><WalletIcon size={24} /></span><div className="section-eyebrow">YOUR LOCAL IDENTITY</div><h2>A wallet for your wonders.</h2><p className="muted">Your browser created a P-256 signing key. No extension, account, or cryptocurrency required.</p><label className="field-label">YOUR RECEIVE ADDRESS</label><div className="address-box">{wallet?.address ?? 'Creating your wallet…'}</div><button className="button dark full" onClick={copyAddress}><Copy size={16} /> Copy wallet address</button><div className="info-box"><strong>This wallet lives in this browser.</strong><p>The non-extractable private key is stored in IndexedDB. Clearing site data or losing this browser profile permanently loses access. There is no recovery or backup in this prototype. Use only for testing.</p></div><p className="wallet-count"><Layers3 size={17} /> {owned.length} cards in your collection</p></>
: dialog === 'transfer' ? <><span className="modal-icon"><ArrowUpRight size={24} /></span><div className="section-eyebrow">PASS ON THE CURIOSITY</div><h2>Send {selected?.article.title}.</h2><p className="muted">The recipient must create a Wikideck wallet on this same local network and share their 64-character receive address.</p><label className="field-label" htmlFor="recipient">RECIPIENT ADDRESS</label><input className="recipient-input" id="recipient" value={recipient} onChange={e => setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" /><div className="info-box"><strong>Transfers cannot be undone.</strong><p>This will sign an ownership transfer. Double-check the address; there is no recovery for mistakes.</p></div>{error && <p className="transfer-error" role="alert">{error}</p>}<button className="button dark full" disabled={transferBusy || !/^[a-fA-F0-9]{64}$/.test(recipient.trim()) || recipient.trim().toLowerCase() === wallet?.address} onClick={transfer}>{transferBusy ? <LoaderCircle className="spin" size={16} /> : <ArrowUpRight size={16} />}{transferBusy ? 'Signing transfer…' : 'Confirm & send card'}</button></>
: selected && <><div className="detail-card"><Card card={selected} /></div><div className="detail-copy"><div className="section-eyebrow">{selected.block ? `DISCOVERED IN BLOCK #${selected.block.height}` : 'EXAMPLE CARD · NOT OWNED'}</div><h2>{selected.article.title}</h2><p>{selected.article.extract}</p><div className="detail-facts"><span>Page ID<strong>#{selected.article.pageId}</strong></span><span>Rarity<strong>{rarity(selected.hash)}</strong></span><span>Content<strong>{selected.article.source === 'live' ? `Revision ${selected.article.revisionId}` : 'Offline starter'}</strong></span></div>{selected.block && <><label className="field-label">VERIFIED PROOF HASH</label><div className="address-box">{selected.hash}</div><p className="muted">Current owner: {short(ownerOf(snapshot!, selected.hash) ?? '')}</p></>}<div className="detail-links"><a href={selected.article.url} target="_blank" rel="noreferrer">Read on Wikipedia <ExternalLink size={14} /></a><a href={imageCredit(selected.article)} target="_blank" rel="noreferrer">Image & license <ExternalLink size={14} /></a></div><p className="attribution">{selected.article.source === 'live' ? 'Text by Wikipedia contributors, CC BY-SA 4.0. Excerpt shortened for card display.' : 'Original starter summary; linked to the real Wikipedia article. No live revision captured.'} Stats are game values, not encyclopedia facts.</p>{selected.block && ownerOf(snapshot!, selected.hash) === wallet?.address && <button className="button dark full" onClick={() => setDialog('transfer')}>Transfer this card <ArrowUpRight size={16} /></button>}</div></>}
{(dialog || selected) && <div className="modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}><section className={`modal ${dialog === 'wallet' ? 'wallet-modal' : selected && !dialog ? 'card-modal' : ''}`} role="dialog" aria-modal="true" aria-label={dialog === 'wallet' ? 'Local testnet wallet' : dialog === 'transfer' ? 'Transfer native NFT' : dialog === 'guide' ? 'How Wikideck works' : 'Card details'}><button className="modal-close" aria-label="Close dialog" onClick={close} disabled={transferBusy}><X size={20} /></button>
{dialog === 'guide' ? <><span className="modal-icon"><Sparkles size={24} /></span><div className="section-eyebrow">THE TESTNET FIELD GUIDE</div><h2>A universe in every card.</h2><div className="guide-steps"><div><span>01</span><div><h3>Mine natively. Only by choice.</h3><p>No browser mining or automatic native execution. Build the trusted RandomX binary and run the miner yourself with an encrypted wallet backup and explicit --consent. One worker defaults to light mode (256 MiB cache plus overhead); --fast adds a ~2 GiB dataset. Mining uses CPU and energy, may warm your device, and is not guaranteed ASIC-proof. Ctrl+C stops it.</p></div></div><div><span>02</span><div><h3>A small catalog. A sovereign chain.</h3><p>Consensus uses four fixed article snapshots, not live random articles. The genesis and chain ID pin this catalog; expanding it currently requires a genesis reset and a new chain. Independent peers validate RandomX blocks, native NFT mints, and signed transactions, choosing the cumulative-work canonical chain. This testnet is unbootstrapped: there is no public mainnet, and an isolated node has no peer-backed network security.</p></div></div><div><span>03</span><div><h3>Keep your keys. Confirm your transfers.</h3><p>Your P-256 wallet is a local signing identity, not a server login. The new testnet wallet is separate from the original demo wallet. Download a password-encrypted backup and restore it locally to control the same NFTs. A submitted transfer is pending, not final ownership. Mints require two subsequent blocks at inclusion height; reorganizations can change confirmations and ownership.</p></div></div></div><div className="info-box"><strong>Native NFTs, not content ownership.</strong><p>Token IDs identify native assets on this chain, not ERC-721 tokens on another network. RandomX proof hashes determine game rarity and stats; block IDs identify blocks. An NFT does not grant copyright or other intellectual-property rights to Wikipedia text or images. Follow their licenses and attribution. No fiat, financial return, or monetary value is promised.</p></div><button className="button dark full" onClick={() => { close(); goMine(); }}>Explore native mining <ArrowRight size={16} /></button></>
: dialog === 'wallet' ? <><span className="modal-icon"><WalletIcon size={24} /></span><div className="section-eyebrow">YOUR LOCAL TESTNET IDENTITY</div><h2>A wallet for your wonders.</h2><p className="muted">No server login. This recoverable wallet controls native NFTs through local signatures. It is separate from the original demo wallet.</p><label className="field-label">YOUR RECEIVE ADDRESS</label><div className="address-box">{wallet?.address ?? 'Wallet unavailable. Enable local browser storage and retry.'}</div><button className="button dark full" onClick={copyAddress} disabled={!wallet}><Copy size={16} /> Copy wallet address</button><div className="wallet-recovery"><WalletPanel wallet={wallet} onRestore={updateWallet} onNotice={setToast} /></div><p className="wallet-count"><Layers3 size={17} /> {owned.length} confirmed native NFTs in your collection</p></>
: dialog === 'transfer' ? <><span className="modal-icon"><ArrowUpRight size={24} /></span><div className="section-eyebrow">PASS ON THE CURIOSITY</div><h2>Send {selected?.article.title}.</h2><p className="muted">Use the recipient’s 64-character testnet wallet address on this same chain. Signing submits a pending transaction; it does not immediately transfer ownership.</p><label className="field-label">NFT TOKEN ID</label><div className="address-box">{selected?.block?.tokenId}</div><label className="field-label" htmlFor="recipient">RECIPIENT ADDRESS</label><input className="recipient-input" id="recipient" value={recipient} disabled={transferBusy} onChange={e => setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" maxLength={64} /><div className="info-box"><strong>Review before signing.</strong><p>Transfers take effect only when mined into the canonical chain. Once confirmed, there is no undo button or recovery for a wrong address. Mints require two subsequent blocks at inclusion height; only one pending transfer per NFT is allowed.</p></div>{(!rpcAvailable || transferDisabledReason) && <p className="transfer-error" role="alert">{!rpcAvailable ? 'RPC unavailable. Reconnect before signing.' : transferDisabledReason}</p>}{transferError && <p className="transfer-error" role="alert">{transferError}</p>}<button className="button dark full" disabled={transferBusy || !rpcAvailable || !!transferDisabledReason || !/^[a-fA-F0-9]{64}$/.test(recipient.trim()) || recipient.trim().toLowerCase() === wallet?.address} onClick={transfer}>{transferBusy ? <LoaderCircle className="spin" size={16} /> : <ArrowUpRight size={16} />}{transferBusy ? 'Signing & submitting…' : 'Sign & submit pending transfer'}</button></>
: selected && <><div className="detail-card"><Card card={selected} /></div><div className="detail-copy"><div className="section-eyebrow">{selected.block ? `MINTED IN BLOCK #${selected.block.height}` : 'EXAMPLE CARD · NOT OWNED'}</div><h2>{selected.article.title}</h2><p>{selected.article.extract}</p><div className="detail-facts"><span>Page ID<strong>#{selected.article.pageId}</strong></span><span>Rarity<strong>{rarity(selected.hash)}</strong></span><span>Content<strong>{selected.article.source === 'live' ? `Pinned revision ${selected.article.revisionId}` : 'Fixed starter snapshot'}</strong></span></div>{selected.block && <><label className="field-label">NATIVE NFT TOKEN ID</label><div className="address-box">{selected.block.tokenId}</div><label className="field-label">BLOCK ID</label><div className="address-box">{selected.block.hash}</div><label className="field-label">RANDOMX PROOF HASH · GAME STATS</label><div className="address-box">{selected.hash}</div><p className="muted">{confirmations} canonical confirmations{!rpcAvailable ? ' · last known state' : ''}. {selectedBlock ? `Confirmed owner: ${selectedOwner}` : 'This mint is not on the loaded canonical chain.'}</p>{pending && <p className="pending-label">Transfer pending mining · confirmed owner unchanged.</p>}</>}<div className="detail-links"><a href={selected.article.url} target="_blank" rel="noreferrer">Read on Wikipedia <ExternalLink size={14} /></a><a href={imageCredit(selected.article)} target="_blank" rel="noreferrer">Image & license <ExternalLink size={14} /></a>{selected.block && <a href={`/api/nfts/${encodeURIComponent(selected.block.tokenId)}`} target="_blank" rel="noreferrer">NFT metadata <ExternalLink size={14} /></a>}</div><p className="attribution">{selected.article.source === 'live' ? 'Pinned text by Wikipedia contributors, CC BY-SA 4.0. Excerpt shortened for card display.' : 'Original fixed starter summary, linked to the real Wikipedia article; no live revision captured.'} Stats are game values, not encyclopedia facts. NFT ownership grants no rights to article text or images.</p>{selected.block && selectedOwner === wallet?.address && <>{transferDisabledReason && <p className="muted">{transferDisabledReason}</p>}<button className="button dark full" disabled={!rpcAvailable || !!transferDisabledReason} onClick={() => { setRecipient(''); setTransferError(''); setDialog('transfer'); }}>Transfer native NFT <ArrowUpRight size={16} /></button></>}</div></>}
</section></div>}
</div>;
}
+4 -2
View File
@@ -1,8 +1,10 @@
import { ArrowUpRight, BookOpen, Globe2, Shield, Sparkles, Swords } from 'lucide-react';
import { rarity, stats, type Article, type Block } from '../shared/protocol.ts';
import { rarity, stats, type Article } from '../shared/protocol.ts';
import type { TestnetBlock } from '../shared/testnet.ts';
import { STARTER_ARTICLES } from '../shared/catalog.ts';
export type CardData = { article: Article; hash: string; block?: Block };
// hash is the RandomX proof hash for game stats; block.tokenId is NFT identity.
export type CardData = { article: Article; hash: string; block?: TestnetBlock };
export const samples: CardData[] = STARTER_ARTICLES.map((article, i) => ({ article,
hash: ['000eca7498a26d1908613726da6e869d7c9c936f4a461cb439ef24672b9a0008',
'000b1a387a067a7d698677965ca1ca7b0eb5e590811431bdb9aad62268180001',
+125
View File
@@ -0,0 +1,125 @@
import { useId, useRef, useState, type FormEvent } from 'react';
import { exportWallet, restoreWallet, type Wallet } from './wallet.ts';
type Props = {
wallet: Wallet | undefined;
onRestore: (wallet: Wallet) => void;
onNotice: (message: string) => void;
};
export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
const id = useId();
const [password, setPassword] = useState('');
const [confirmation, setConfirmation] = useState('');
const [importPassword, setImportPassword] = useState('');
const [file, setFile] = useState<File>();
const [replace, setReplace] = useState(false);
const [busy, setBusy] = useState(false);
const inProgress = useRef(false);
const fileInput = useRef<HTMLInputElement>(null);
async function download(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (inProgress.current || !wallet) return;
if (password.length < 12 || password.length > 1024 || password !== confirmation) {
onNotice('Use a password of 12–1024 characters and enter it identically twice.');
return;
}
inProgress.current = true;
setBusy(true);
try {
const backup = await exportWallet(wallet, password);
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = `wikideck-testnet-wallet-${wallet.address}.json`;
document.body.append(link);
try { link.click(); }
finally {
link.remove();
// Let the browser start consuming the download before revocation.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
onNotice('Encrypted backup download requested. Keep the file and password safe; test recovery before relying on it.');
} catch { onNotice('Could not export the wallet. Check browser support and try again.'); }
finally {
setPassword('');
setConfirmation('');
inProgress.current = false;
setBusy(false);
}
}
async function recover(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (inProgress.current) return;
if (!file || file.size === 0 || file.size > 32 * 1024) {
onNotice('Choose a nonempty wallet backup JSON file no larger than 32 KB.');
return;
}
if (wallet && !replace) {
onNotice('Confirm replacement of the active wallet first. Back it up before continuing.');
return;
}
inProgress.current = true;
setBusy(true);
try {
// Size checked before reading; the shared decoder validates all JSON fields.
const backup: unknown = JSON.parse(await file.text());
const recovered = await restoreWallet(backup, importPassword);
onRestore(recovered);
onNotice('Wallet recovered and saved locally. Other open tabs should reload before signing.');
setFile(undefined);
setReplace(false);
if (fileInput.current) fileInput.current.value = '';
} catch {
onNotice('Recovery failed. Check the password, backup format and network, and browser storage. The backup must be intact.');
} finally {
setImportPassword('');
inProgress.current = false;
setBusy(false);
}
}
return <section aria-labelledby={`${id}-title`} aria-busy={busy}>
<h2 id={`${id}-title`}>Wallet backup &amp; recovery</h2>
<p>This is a recoverable P-256 wallet for wikideck-randomx-testnet-2, separate from your old demo wallet.</p>
<p>The exportable private key is stored locally in this browser’s IndexedDB, not encrypted by this password.
Scripts running on this origin or someone with access to your browser profile may access it.
Clearing site data without a backup loses access.</p>
<p>Backup encryption and recovery happen locally. No private key, backup file, or password is transmitted to a server
by this panel. Public addresses, public keys and signatures may be shared during normal wallet use.</p>
<p><strong>Forgotten backup passwords cannot be reset or recovered.</strong> Keep the encrypted file and a strong,
unique password safe and separate. Anyone with both can control this wallet. Downloading does not guarantee the file was saved.</p>
<form onSubmit={download}>
<fieldset disabled={busy || !wallet}>
<legend>Download encrypted backup</legend>
<label htmlFor={`${id}-password`}>Backup password (at least 12 characters)</label>
<input id={`${id}-password`} type="password" autoComplete="new-password" minLength={12} maxLength={1024}
required value={password} onChange={event => setPassword(event.target.value)} />
<label htmlFor={`${id}-confirm`}>Confirm backup password</label>
<input id={`${id}-confirm`} type="password" autoComplete="new-password" minLength={12} maxLength={1024}
required value={confirmation} onChange={event => setConfirmation(event.target.value)} />
<button type="submit">Download encrypted backup</button>
</fieldset>
</form>
<form onSubmit={recover}>
<fieldset disabled={busy}>
<legend>Recover from a local backup</legend>
<label htmlFor={`${id}-file`}>Backup JSON file (maximum 32 KB)</label>
<input ref={fileInput} id={`${id}-file`} type="file" accept=".json,application/json" required
onChange={event => { setFile(event.target.files?.[0]); setReplace(false); }} />
<label htmlFor={`${id}-import-password`}>Backup password</label>
<input id={`${id}-import-password`} type="password" autoComplete="off" required maxLength={1024}
value={importPassword} onChange={event => setImportPassword(event.target.value)} />
{wallet && <label>
<input type="checkbox" required checked={replace} onChange={event => setReplace(event.target.checked)} />
I have backed up my active wallet and confirm replacing it. Recovery does not transfer its assets.
</label>}
<button type="submit" disabled={!file || (!!wallet && !replace)}>Recover and save wallet</button>
</fieldset>
</form>
</section>;
}
export default WalletPanel;
+62
View File
@@ -0,0 +1,62 @@
/* Testnet-only layout and forms. All colors follow the active Catppuccin flavor. */
.testnet-brand { display: inline-flex; gap: 7px; align-items: center; white-space: nowrap; font-size: 11px; color: var(--ctp-subtext1); }
.testnet-brand b { padding: 4px 6px; border: 1px solid var(--ctp-surface1); border-radius: 5px; font-size: 9px; letter-spacing: .8px; color: var(--theme-accent); background: var(--theme-accent-soft); }
.testnet-status { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; margin-bottom: 20px; font-size: 11px; line-height: 1.6; color: var(--ctp-subtext0); }
.testnet-status > span:first-child { display: inline-flex; align-items: center; gap: 8px; }
.testnet-status strong, .pending-label { color: var(--ctp-peach); }
.pending-label { display: inline-block; font-size: 11px; line-height: 1.6; margin-top: 6px; }
.native-mining { align-items: start; }
.native-mining > *, .native-mining .miner-display > div { min-width: 0; }
.native-mining .miner-display strong { max-width: none; white-space: normal; line-height: 1.6; }
.native-mining .muted { font-size: 12px; line-height: 1.8; margin-top: 8px; }
.native-memory { display: grid; gap: 7px; margin-top: 22px; padding: 16px; border: 1px solid var(--theme-border); border-radius: 9px; background: var(--ctp-base); font-size: 12px; line-height: 1.7; }
.native-memory strong { color: var(--ctp-lavender); }
.native-memory span { color: var(--ctp-subtext0); }
.native-memory strong:not(:first-child) { margin-top: 6px; }
.native-steps { padding-left: 20px; margin: 18px 0; color: var(--ctp-subtext1); font-size: 12px; line-height: 1.8; }
.native-steps li + li { margin-top: 8px; }
.native-command { margin: 14px 0; padding: 16px; border: 1px solid var(--ctp-surface1); border-radius: 8px; background: var(--ctp-crust); color: var(--ctp-green); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.9; user-select: text; }
.native-mining .miner-footnote { display: block; margin: 14px 0 20px; font-size: 11px; line-height: 1.9; overflow-wrap: anywhere; }
.native-mining .text-button { margin-top: 12px; }
.chain-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 22px 0; border: 1px solid var(--theme-border); border-radius: 10px; overflow: hidden; background: var(--theme-border); }
.chain-summary > div { padding: 16px 20px; background: var(--ctp-mantle); }
.chain-summary dt { color: var(--ctp-subtext0); font-size: 11px; margin-bottom: 8px; }
.chain-summary dd { margin: 0; font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; color: var(--ctp-text); }
.explorer-card-link { padding: 8px 0; background: transparent; color: var(--theme-accent); font: inherit; text-decoration: underline; text-underline-offset: 3px; }
.nft-link { display: flex; align-items: center; gap: 6px; margin-top: 6px; color: var(--ctp-blue); }
.proof-caption { display: block; margin-bottom: 6px; font-size: 11px; }
.wallet-modal { width: min(680px, 100%); }
.wallet-recovery { margin-top: 28px; border-top: 1px solid var(--theme-border); padding-top: 24px; }
.wallet-recovery h2 { font-size: 22px; }
.wallet-recovery p { margin: 12px 0; color: var(--ctp-subtext0); font-size: 12px; line-height: 1.9; }
.wallet-recovery p strong { color: var(--ctp-peach); }
.wallet-recovery fieldset { display: grid; min-width: 0; gap: 10px; padding: 18px; margin: 20px 0; border: 1px solid var(--ctp-surface1); border-radius: 10px; background: var(--ctp-mantle); }
.wallet-recovery legend { padding: 0 8px; font-size: 13px; color: var(--ctp-lavender); font-weight: 600; }
.wallet-recovery label { color: var(--ctp-subtext1); font-size: 12px; line-height: 1.8; }
.wallet-recovery input:not([type='checkbox']) { display: block; width: 100%; min-width: 0; border: 1px solid var(--ctp-surface1); border-radius: 6px; padding: 10px 12px; background: var(--ctp-base); color: var(--ctp-text); font: inherit; font-size: 13px; }
.wallet-recovery input[type='checkbox'] { margin-right: 8px; accent-color: var(--theme-accent); }
.wallet-recovery input[type='file'] { padding: 9px; font-size: 11px; }
.wallet-recovery input::file-selector-button { padding: 6px 8px; margin-right: 10px; border: 0; border-radius: 4px; background: var(--ctp-surface0); color: var(--ctp-text); }
.wallet-recovery button { min-height: 42px; margin-top: 8px; padding: 10px 16px; border: 0; border-radius: 6px; background: var(--theme-accent); color: var(--theme-accent-ink); font: inherit; font-size: 12px; font-weight: 600; }
.wallet-recovery button:hover:not(:disabled) { filter: brightness(1.08); }
.wallet-recovery button:disabled { opacity: .5; cursor: not-allowed; }
.wallet-recovery input:focus-visible, .wallet-recovery button:focus-visible, .explorer-card-link:focus-visible { outline: 2px solid var(--theme-accent); outline-offset: 3px; }
.detail-copy .muted, .detail-copy .address-box { overflow-wrap: anywhere; }
.detail-links { flex-wrap: wrap; row-gap: 10px; }
.toast { max-height: 40vh; overflow-y: auto; }
@media (max-width: 1100px) {
.testnet-brand { font-size: 10px; }
.top-actions { gap: 8px; }
}
@media (max-width: 760px) {
.testnet-status { font-size: 10px; gap: 8px 14px; }
.chain-summary { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 560px) {
.testnet-brand { flex-direction: column; gap: 3px; font-size: 9px; }
.testnet-brand b { font-size: 8px; padding: 2px 4px; }
.native-command { font-size: 11px; padding: 12px; }
.native-mining .miner-footnote { font-size: 11px; }
.wallet-recovery fieldset { padding: 14px; }
.wallet-recovery h2 { font-size: 20px; }
}
+93 -30
View File
@@ -1,41 +1,104 @@
import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
export type Wallet = { privateKey: CryptoKey; publicKey: string; address: string };
const DATABASE = 'wikideck-testnet-wallet-v2';
const LOCK = `${DATABASE}:identity`;
const encode = (bytes: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(bytes)));
async function loadOrCreate(): Promise<Wallet> {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('wikideck-wallet', 1);
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE, 1);
let blocked = false;
request.onupgradeneeded = () => request.result.createObjectStore('keys');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(new Error('Enable browser storage to create your local wallet.'));
request.onsuccess = () => {
if (blocked) { request.result.close(); return; }
request.result.onversionchange = () => request.result.close();
resolve(request.result);
};
request.onblocked = () => {
blocked = true;
reject(new Error('Close other Wikideck tabs to enable wallet storage, then retry.'));
};
request.onerror = () => reject(request.error ?? new Error('Enable browser storage to use your local wallet.'));
});
try {
const saved = await new Promise<Wallet | undefined>((resolve, reject) => {
const req = db.transaction('keys').objectStore('keys').get('identity');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
if (saved) return saved;
const keys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
}
// Resolve only on transaction commit, not a request's earlier success event.
function identity(db: IDBDatabase, replacement?: Wallet, createOnly = false): Promise<Wallet | undefined> {
return new Promise((resolve, reject) => {
const tx = db.transaction('keys', replacement ? 'readwrite' : 'readonly');
const store = tx.objectStore('keys');
let result: Wallet | undefined;
const request = store.get('identity');
request.onsuccess = () => {
try {
result = request.result as Wallet | undefined;
if (replacement && (!createOnly || result === undefined)) {
store.put(replacement, 'identity');
result = replacement;
}
} catch (error) {
tx.abort();
reject(error);
}
};
tx.oncomplete = () => resolve(result);
tx.onabort = () => reject(tx.error ?? new DOMException('Wallet transaction aborted.', 'AbortError'));
tx.onerror = () => reject(tx.error ?? new Error('Could not save the wallet.'));
});
}
// Serialize this tab even without Web Locks. Atomic create-only transactions
// prevent first-use races across tabs without navigator.locks as well.
let queue: Promise<unknown> = Promise.resolve();
function withStorage<T>(operation: (db: IDBDatabase) => Promise<T>): Promise<T> {
const run = async () => {
for (let attempt = 0; ; attempt++) {
let db: IDBDatabase | undefined;
try {
db = await openDatabase();
return await operation(db);
} catch (error) {
if (attempt >= 2 || !(error instanceof DOMException) ||
!['AbortError', 'UnknownError', 'InvalidStateError'].includes(error.name)) throw error;
} finally { db?.close(); }
}
};
const result = queue.then(async (): Promise<T> => {
if (typeof navigator !== 'undefined' && navigator.locks) {
return await navigator.locks.request(LOCK, run);
}
return await run();
});
queue = result.catch(() => undefined);
return result;
}
export function getWallet(): Promise<Wallet> {
return withStorage(async db => {
const saved = await identity(db);
if (saved !== undefined) return saved;
// Intentional exportability: IndexedDB is NOT password-encrypted. Only the
// separate portable backup is protected by the user's backup password.
const keys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const spki = await crypto.subtle.exportKey('spki', keys.publicKey);
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', spki));
const wallet = { privateKey: keys.privateKey, publicKey: encode(spki),
address: Array.from(digest, b => b.toString(16).padStart(2, '0')).join('') };
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put(wallet, 'identity');
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return wallet;
} finally { db.close(); }
}
let pending: Promise<Wallet> | undefined;
export function getWallet(): Promise<Wallet> {
return pending ??= (async () => {
if (navigator.locks) return await navigator.locks.request('wikideck-wallet-init', loadOrCreate);
return loadOrCreate();
})().catch(error => { pending = undefined; throw error; });
const wallet: Wallet = { privateKey: keys.privateKey, publicKey: encode(spki),
address: Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('') };
return (await identity(db, wallet, true))!;
});
}
export async function sign(wallet: Wallet, message: string): Promise<string> {
return encode(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, wallet.privateKey, new TextEncoder().encode(message)));
}
export function exportWallet(wallet: Wallet, password: string): Promise<WalletBackup> {
return encryptWallet(wallet.privateKey, wallet.publicKey, password);
}
export async function restoreWallet(backup: unknown, password: string): Promise<Wallet> {
const wallet = await decryptWallet(backup, password);
await withStorage(db => identity(db, wallet));
return wallet;
}