import { useEffect, useRef, useState } from 'react'; import { ArrowDownLeft, ArrowLeftRight, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp, 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, isNftTrade, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts'; import { getWallet, sign, subscribeWallet, watchWalletLifecycle, 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 { TradeDesk } from './TradeDesk.tsx'; import { IS_MAINNET, NETWORK_GENESIS_TIME, NETWORK_LABEL } from '../shared/network.ts'; import { StateClient } from '../shared/rpc-client.ts'; import './App.css'; import './testnet.css'; type View = 'Discover' | 'My collection' | 'Block explorer' | 'Trade cards'; 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(path: string, body?: unknown, expectedStatus?: number): Promise { 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 Wikideck node RPC is unavailable. Start the node and web services.'); const data = await res.json(); if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Request failed'); if (expectedStatus !== undefined && res.status !== expectedStatus) throw new Error('Unexpected node 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(tx => transactionCardIds(tx).includes(block.tokenId))) return 'A transfer or trade involving this NFT is already pending mining.'; return ''; } export default function App() { const [view, setView] = useState('Discover'); const [wallet, setWallet] = useState(); const [snapshot, setSnapshot] = useState(); 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(); const [query, setQuery] = useState(''); const [category, setCategory] = useState('All cards'); const [recipient, setRecipient] = useState(''); const [transferBusy, setTransferBusy] = useState(false); const [transferError, setTransferError] = useState(''); const transferInFlight = useRef(false); const refreshVersion = useRef(0); const minerPanel = useRef(null); const stateClient = useRef(new StateClient()); useModalFocus(Boolean(dialog || selected)); const refresh = async () => { const version = ++refreshVersion.current; try { const data = await stateClient.current.load(); if (data.network !== NETWORK || data.algorithm !== 'RandomX' || typeof data.chainId !== 'string' || !Array.isArray(data.blocks) || !Array.isArray(data.transfers) || !Array.isArray(data.pendingTransfers)) throw new Error(`Incompatible node. This interface is pinned to ${NETWORK} at genesis ${NETWORK_GENESIS_TIME}.`); if (version === refreshVersion.current) { setSnapshot(data); setRpcAvailable(true); setError(''); } return data; } catch (e) { if (version === refreshVersion.current) { setRpcAvailable(false); setError(message(e)); } throw e; } }; useEffect(() => { let active = true; const stopLifecycle = watchWalletLifecycle(); const unsubscribe = subscribeWallet(() => { void getWallet().then(value => { if(active) {setWallet(value);setRecipient('');setTransferError('');} }).catch(()=>{if(active)setWallet(undefined);}); }); 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; unsubscribe(); stopLifecycle(); clearInterval(timer); refreshVersion.current++; }; }, []); useEffect(() => { 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); }, []); const transfer = async () => { if (transferInFlight.current || !wallet || !selected?.block || !snapshot) return; transferInFlight.current = true; setTransferBusy(true); setTransferError(''); try { 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 => transactionCardIds(tx).includes(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 => !transactionCardIds(p).includes(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(tx => transactionCardIds(tx).includes(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 --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 copyAddress = async () => { if (!wallet) return; try { await navigator.clipboard.writeText(wallet.address); setToast('Wallet address copied.'); } 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 () => { try { setWallet(await getWallet()); await refresh(); } catch (e) { setError(message(e)); } }; return
Explore {view}
RandomX {NETWORK_LABEL}{peerLabel}
RPC {rpcAvailable ? 'available' : 'unavailable'}{peerLabel}{IS_MAINNET?'MAINNET · experimental · unaudited · no value promised':'TESTNET · experimental · no value promised'}{snapshot && !rpcAvailable && Showing last known state}
{error &&
{error}
} {view === 'Discover' && <>
MINE · COLLECT · TRADE CARDS

Mine with your CPU.
Collect the world.

Turn illustrated Wikipedia discoveries into collectible cards. Mine with RandomX, build a collection around what you love, and trade cards directly with other collectors.

CPU-friendly RandomX Direct card-for-card trades
✳+
Preview cards · not owned
setSelected(samples[2])} />
setSelected(samples[0])} />
A world worth collecting
Native NFTs minted{snapshot?.blocks.length.toLocaleString() ?? '—'}canonical chain
Block height{snapshot ? `#${snapshot.blocks.at(-1)?.height ?? 0}` : '—'}current tip
Confirmed ownership{wallet && snapshot ? owned.length : '—'}in your wallet
Proof of work{snapshot?.algorithm ?? 'RandomX'}native CPU mining
RANDOMX · DESIGNED FOR GENERAL-PURPOSE CPUS

Mining should be for more people.

Wikideck and Monero use RandomX, an algorithm designed to resist specialized ASICs and keep general-purpose CPUs competitive. That can make mining more accessible—not make every machine equally powerful or promise rewards. Each accepted Wikideck block earns its miner a distinct illustrated Wikipedia card. Wikideck is its own independent network.

Mine with a CPU Independently validating peers
Hardware, rewards & network details
Light · default256 MiB cache plus scratchpad and process overhead.Fast · --fastAbout 2 GiB dataset plus cache; budget over 2.3 GiB per process. Mining uses CPU, memory, electricity and time. ASIC resistance is a design goal, not a guarantee; results depend on hardware and network difficulty. Wikideck does not share Monero’s network, hashpower or security.
NEXT WIKIPEDIA ARTICLE{snapshot ? 'Chosen when the miner requests work' : `Waiting for ${NETWORK_LABEL} RPC…`}

The miner selects a random illustrated English Wikipedia article. The same article can appear again; each card keeps its own mint-time statistics.

Want to run a miner? Setup & safety details
  1. Build wikideck-randomx using the repository’s native build instructions.
  2. Save your encrypted backup and keep its password separate.
  3. Replace <backup.json> with your local file path (quote paths with spaces), then run:
{nativeCommand}

Node: {window.location.origin}. The command connects to this node; use HTTPS for remote nodes. Password entry stays local—never put it in the command. It defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.

MINE · EARN A CARD

Discover the next card.

Run the native RandomX miner to compete for blocks. Each accepted block rewards its miner with a distinct illustrated Wikipedia card.

COLLECT · BUILD YOUR DECK

Make every collection your own.

Browse discoveries on the chain, keep cards in your local wallet, and follow the subjects that interest you.

TRADE · PLAYER TO PLAYER

Swap cards directly.

Prepare a signed offer for another collector and exchange card bundles atomically when the trade is mined. No prices or checkout.

} {view === 'Trade cards' ? : view !== 'Block explorer' ?
{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}

{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}{visibleCards.length}

{view === 'My collection' ? 'Confirmed native NFT ownership. Pending transfers stay here until mined.' : snapshot?.blocks.length ? 'Recent native NFTs on this node’s canonical chain.' : 'Sample previews only; mined NFTs use random Wikipedia pages with pictures. The same page may be minted more than once.'}

{view === 'Discover' && }
{['All cards', 'Nature', 'Space', 'Science', 'People', 'History', 'Culture'].map(c => )}
{filtered.length ?
{filtered.map(c =>
{ setSelected(c); setTransferError(''); }} />
{c.block ? `Block #${c.block.height}` : 'Example discovery'}{c.block && snapshot ? short(testnetOwnerOf(snapshot, c.block.tokenId) ?? '') : 'Not owned'}
{c.block && snapshot?.pendingTransfers.some(tx => transactionCardIds(tx).includes(c.block!.tokenId)) && Transfer or trade pending mining}
)}
:
Wikideck

{query || category !== 'All cards' ? 'No discoveries here. Yet.' : 'Your first discovery is waiting.'}

{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.'}

}
:
FOLLOW THE CANONICAL CHAIN

Block explorer{snapshot?.blocks.length ?? 0}

Native NFT mints, RandomX proofs, and mined ownership transfers.

View full chain JSON

Sovereign network with independently validating peers and cumulative-work chain selection. This network is experimental and may be unbootstrapped. Zero peers means an isolated node, not a connected network. Canonical ownership can change during a reorganization.

Cumulative work
{snapshot?.cumulativeWork ?? '—'}
Peer count
{rpcAvailable ? snapshot?.peers ?? '—' : 'Unknown'} · {peerLabel}
Chain ID · pinned genesis
{snapshot?.chainId ?? '—'}
Network
{NETWORK}
Tip block ID
{snapshot?.tip ?? '—'}
Mint maturity
{snapshot?.maturity ?? CONSENSUS.coinbaseMaturity} subsequent blocks at inclusion height
{snapshot?.blocks.length ?
{[...snapshot.blocks].reverse().map(b => )}
Block / confirmationsDiscoveryBlock ID / NFT token IDConfirmed ownerRandomX proof / rarity
{snapshot.blocks.length - b.height + 1} confirmations{b.article.title}Wikipedia #{b.article.pageId}Block {short(b.hash)}NFT {short(b.tokenId)} {short(testnetOwnerOf(snapshot, b.tokenId) ?? '')}{short(b.powHash)}{rarity(b.powHash)}
:
Wikideck

A new chain. A blank page.

No NFT blocks loaded yet. Mine a block to earn the next Wikipedia card.

}

Mined transfers and trades

{snapshot?.transfers.length ?
{snapshot.transfers.map((tx, i) => isNftTrade(tx) ?
{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}Atomic trade · mined #{snapshot.blocks.find(b => b.transactions.some(item => isNftTrade(item) && item.proposerSignature === tx.proposerSignature))?.height ?? '—'}
:
{short(tx.cardHash)}{short(tx.from)} → {short(tx.to)}Sequence {tx.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(item => !isNftTrade(item) && item.signature === tx.signature))?.height ?? '—'}
)}
:

No mined transfers or trades.

}

Pending transfers and trades {snapshot?.pendingTransfers.length ?? 0}

Mempool only · not confirmed ownership. A trade bundle is included in full or not at all; pending transactions may be dropped or invalidated by a reorganization.

{snapshot?.pendingTransfers.map((tx, i) => isNftTrade(tx) ?
{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}Atomic trade · awaiting mining
:
{short(tx.cardHash)}{short(tx.from)} → {short(tx.to)}Awaiting mining
)}
}
Wikipedia pages. Collectible discoveries.Mine. Collect. Trade.✦

Independent project, not affiliated with Wikimedia or Monero. NFT rewards represent Wikipedia article snapshots, not ownership of Wikipedia pages or their copyright. Attribution and image licenses appear on each card. Current network: {NETWORK_LABEL}; experimental, with no promised monetary value.

{toast &&
{toast}
} {(dialog || selected) &&
{ if (e.target === e.currentTarget) close(); }}>
{dialog === 'guide' ? <>
THE WIKIDECK FIELD GUIDE

Mine. Collect. Trade cards.

01

Mine with a CPU. Discover a card.

RandomX—the algorithm used by Wikideck and Monero—is designed to make specialized ASICs less advantageous and general-purpose CPUs more competitive. Mine a block to earn a one-of-a-kind card featuring a random illustrated Wikipedia article.

Mining, rewards & network facts

ASIC resistance is a design goal, not a guarantee of equal performance. Results depend on hardware and network difficulty; there is no promised reward rate or profit. Wikideck is an independent network and does not share Monero’s hashpower or security. Mining uses CPU, memory, electricity and time.

02

Keep your collection in your wallet.

Your encrypted local wallet controls your cards. Restore a backup to use the same identity in another browser profile. Confirmed ownership follows the canonical chain.

Wallet and ownership basics

Transfers remain pending until confirmed, and a chain reorganization can change confirmed ownership. Looking up a public wallet address reveals its confirmed holdings. Keep your encrypted backup and password separately.

03

Trade cards directly with another collector.

Both players sign the same card list; the complete swap is applied together when mined or rejected. Offers are shared between players, not sold or submitted as listings.

What happens when I accept?

The app rechecks ownership, maturity, sequences, and pending activity before submitting. A trade stays pending until mined; re-check the recipient and cards before signing.

Collectible snapshots—not ownership of Wikipedia.

Cards do not grant rights to Wikipedia text or images. Check each card’s attribution and licenses. This is an experimental {NETWORK_LABEL.toLowerCase()} network; cards have no promised monetary value.

: dialog === 'wallet' ? <>
YOUR LOCAL {NETWORK_LABEL} IDENTITY

A wallet for your wonders.

No server login. This recoverable wallet controls native NFTs through local signatures. It is separate from the original demo wallet.

{wallet?.address ?? 'Wallet unavailable. Enable local browser storage and retry.'}

{owned.length} confirmed native NFTs in your collection

: dialog === 'transfer' ? <>
PASS ON THE CURIOSITY

Send {selected?.article.title}.

Use the recipient’s 64-character wallet receive address on this same network. Signing submits a pending transaction; it does not immediately transfer ownership.

{selected?.block?.tokenId}
setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" maxLength={64} />
Review before signing.

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.

{(!rpcAvailable || transferDisabledReason) &&

{!rpcAvailable ? 'RPC unavailable. Reconnect before signing.' : transferDisabledReason}

}{transferError &&

{transferError}

} : selected && <>
{selected.block ? `MINTED IN BLOCK #${selected.block.height}` : 'EXAMPLE CARD · NOT OWNED'}

{selected.article.title}

{selected.article.extract}

Page ID#{selected.article.pageId}Rarity{rarity(selected.hash)}Content{selected.article.source === 'live' ? `Pinned revision ${selected.article.revisionId}` : 'Sample preview'}
{selected.block && <>
{selected.block.tokenId}
{selected.block.hash}
{selected.hash}

{confirmations} canonical confirmations{!rpcAvailable ? ' · last known state' : ''}. {selectedBlock ? `Confirmed owner: ${selectedOwner}` : 'This mint is not on the loaded canonical chain.'}

{pending &&

Transfer pending mining · confirmed owner unchanged.

}}

{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.

{selected.block && selectedOwner === wallet?.address && <>{transferDisabledReason &&

{transferDisabledReason}

}}
}
}
; }