Trades
This commit is contained in:
+19
-14
@@ -1,19 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowDownLeft, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp,
|
||||
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, nftTransferMessage, testnetOwnerOf,
|
||||
import { CONSENSUS, NETWORK, isNftTrade, nftTransferMessage, testnetOwnerOf, transactionCardIds,
|
||||
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 { TradeDesk } from './TradeDesk.tsx';
|
||||
import './App.css';
|
||||
import './testnet.css';
|
||||
|
||||
type View = 'Discover' | 'My collection' | 'Block explorer';
|
||||
type View = 'Discover' | 'My collection' | 'Block explorer' | 'Trade decks';
|
||||
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.';
|
||||
@@ -35,7 +36,7 @@ function transferProblem(state: TestnetSnapshot, card: CardData, address: string
|
||||
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.';
|
||||
if (state.pendingTransfers.some(tx => transactionCardIds(tx).includes(block.tokenId))) return 'A transfer or trade involving this NFT is already pending mining.';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -102,13 +103,13 @@ export default function App() {
|
||||
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 };
|
||||
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 => p.cardHash !== tx.cardHash), tx] } : 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(() => {});
|
||||
@@ -124,7 +125,7 @@ export default function App() {
|
||||
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 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 <backup.json> --consent --node ${window.location.origin}`;
|
||||
@@ -153,8 +154,8 @@ export default function App() {
|
||||
<aside className="sidebar">
|
||||
<a href="#" className="brand" onClick={e => { e.preventDefault(); setView('Discover'); }}><img className="brand-logo" src="/Wikideck.png" alt="" />wikideck</a>
|
||||
<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];
|
||||
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer', 'Trade decks'] as View[]).map((item, i) => {
|
||||
const Icon = [LayoutDashboard, Layers3, Globe2, ArrowLeftRight][i];
|
||||
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 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>
|
||||
@@ -182,23 +183,27 @@ export default function App() {
|
||||
<div className="miner-controls"><div className="miner-display"><div><span>NEXT WIKIPEDIA ARTICLE</span><strong>{snapshot ? 'Chosen when the miner requests work' : 'Waiting for testnet RPC…'}</strong></div></div><p className="muted">Each mining template requests a random English Wikipedia article with a Wikimedia picture. Pages may repeat; every minted card carries its own statistics snapshot.</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><backup.json></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">
|
||||
{view === 'Trade decks' ? <TradeDesk wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : 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' ? 'Confirmed native NFT ownership. Pending transfers stay here until mined.' : snapshot?.blocks.length ? 'Recent native NFTs on this node’s canonical testnet chain.' : 'Sample previews only; mined NFTs use random Wikipedia pages with pictures. The same page may be minted more than once.'}</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.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"><img className="empty-state-logo" src="/Wikideck.png" alt="Wikideck" /><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>}
|
||||
{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(tx => transactionCardIds(tx).includes(c.block!.tokenId)) && <span className="pending-label">Transfer or trade pending mining</span>}</div>)}</div> : <div className="empty-state"><img className="empty-state-logo" src="/Wikideck.png" alt="Wikideck" /><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"><img className="empty-state-logo" src="/Wikideck.png" alt="Wikideck" /><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>
|
||||
<h2 className="transfer-title">Mined transfers and trades</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((tx, i) => isNftTrade(tx)
|
||||
? <div key={`trade-${tx.proposerSignature}`}><ArrowLeftRight size={18}/><span>{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}</span><span>Atomic trade · mined #{snapshot.blocks.find(b => b.transactions.some(item => isNftTrade(item) && item.proposerSignature === tx.proposerSignature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined trade ${i + 1}`}/></div>
|
||||
: <div key={`${tx.cardHash}-${tx.sequence}`}><ArrowDownLeft size={18}/><a href={`/api/nfts/${encodeURIComponent(tx.cardHash)}`} target="_blank" rel="noreferrer" title={tx.cardHash}>{short(tx.cardHash)}</a><span>{short(tx.from)} → {short(tx.to)}</span><span>Sequence {tx.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(item => !isNftTrade(item) && item.signature === tx.signature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined transfer ${i + 1}`}/></div>)}</div> : <p className="muted">No mined transfers or trades.</p>}
|
||||
<h2 className="transfer-title">Pending transfers and trades <span className="count-badge">{snapshot?.pendingTransfers.length ?? 0}</span></h2><p className="muted">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.</p><div className="journal">{snapshot?.pendingTransfers.map((tx, i) => isNftTrade(tx)
|
||||
? <div key={`pending-trade-${tx.proposerSignature}`}><LoaderCircle size={16}/><span>{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}</span><span className="pending-label">Atomic trade · awaiting mining</span></div>
|
||||
: <div key={`${tx.cardHash}-${tx.sequence}`}><LoaderCircle size={16}/><span title={tx.cardHash}>{short(tx.cardHash)}</span><span>{short(tx.from)} → {short(tx.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>Knowledge, not financial promises.<span className="footer-star">✦</span></span><p>Independent experiment, not affiliated with Wikimedia. Mined article snapshots are selected from Wikipedia; attribution and image licenses appear 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 ${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 page, a snapshot. A sovereign chain.</h3><p>Each mining template asks Wikipedia for a random English article with a picture. A page can appear on multiple NFTs; each block commits its own article and mint-time statistics snapshot. Validators verify the snapshot's structure and commitments without contacting Wikimedia during consensus validation. Independent peers validate RandomX work, 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 === '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 page, a snapshot. A sovereign chain.</h3><p>Each mining template asks Wikipedia for a random English article with a picture. A page can appear on multiple NFTs; each block commits its own article and mint-time statistics snapshot. Validators verify the snapshot's structure and commitments without contacting Wikimedia during consensus validation. Independent peers validate RandomX work, 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. Trade as a team.</h3><p>Your P-256 wallet is a local signing identity, not a server login. Download a password-encrypted backup and restore it locally to control your NFTs. Use Trade decks to review a public wallet’s confirmed holdings and prepare a signed multi-card offer. The other player signs before submission; all cards exchange together when the single trade transaction is mined. Offers do not reserve cards and can go stale. A pending transfer or trade does not change confirmed 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}` : 'Sample preview'}</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></>}
|
||||
|
||||
Executable
+224
@@ -0,0 +1,224 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowLeftRight, Check, Clipboard, LoaderCircle, RefreshCw, Send } from 'lucide-react';
|
||||
import { CONSENSUS, NETWORK, isNftTrade, nftTradeMessage, type NftTrade, type TestnetDeck, type TestnetSnapshot } from '../shared/testnet.ts';
|
||||
import { tradeOfferSchema, type TradeOffer } from '../shared/trade-offer.ts';
|
||||
import type { Wallet } from './wallet.ts';
|
||||
import './trade.css';
|
||||
|
||||
type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean; onNotice: (message: string) => void };
|
||||
type OwnedCard = TestnetDeck['nfts'][number];
|
||||
const addressPattern = /^[a-f0-9]{64}$/;
|
||||
const short = (value: string) => `${value.slice(0, 10)}…${value.slice(-6)}`;
|
||||
async function api<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`/api/${path}`, { cache: 'no-store', signal: AbortSignal.timeout(15000),
|
||||
...(body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) });
|
||||
const data: unknown = await response.json();
|
||||
if (!response.ok) throw new Error(data && typeof data === 'object' && 'error' in data && typeof data.error === 'string'
|
||||
? data.error : 'Trade request failed');
|
||||
return data as T;
|
||||
}
|
||||
async function verifyOfferSignature(offer: TradeOffer): Promise<boolean> {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(offer.proposerPublicKey), value => value.charCodeAt(0));
|
||||
const publicKey = await crypto.subtle.importKey('spki', bytes, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
|
||||
const canonical = new Uint8Array(await crypto.subtle.exportKey('spki', publicKey));
|
||||
const addressBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', canonical));
|
||||
const address = Array.from(addressBytes, value => value.toString(16).padStart(2, '0')).join('');
|
||||
if (address !== offer.proposer) return false;
|
||||
const signature = Uint8Array.from(atob(offer.proposerSignature), value => value.charCodeAt(0));
|
||||
return await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, publicKey, signature,
|
||||
new TextEncoder().encode(nftTradeMessage(offer)));
|
||||
} catch { return false; }
|
||||
}
|
||||
function ownershipSequence(state: TestnetSnapshot, tokenId: string): number {
|
||||
return state.transfers.reduce((count, tx) => count + (
|
||||
isNftTrade(tx)
|
||||
? Number(tx.offered.some(leg => leg.cardHash === tokenId) || tx.requested.some(leg => leg.cardHash === tokenId))
|
||||
: Number(tx.cardHash === tokenId)), 0);
|
||||
}
|
||||
function ownsFrom(snapshot: TestnetSnapshot, tokenId: string, owner: string): boolean {
|
||||
let current = snapshot.blocks.find(block => block.tokenId === tokenId)?.owner;
|
||||
for (const tx of snapshot.transfers) {
|
||||
if (isNftTrade(tx)) {
|
||||
if (tx.offered.some(leg => leg.cardHash === tokenId)) current = tx.counterparty;
|
||||
if (tx.requested.some(leg => leg.cardHash === tokenId)) current = tx.proposer;
|
||||
} else if (tx.cardHash === tokenId) current = tx.to;
|
||||
}
|
||||
return current === owner;
|
||||
}
|
||||
function DeckList({ title, cards, selected, onToggle, disabled }: {
|
||||
title: string; cards: OwnedCard[]; selected: Set<string>; onToggle: (id: string) => void; disabled?: boolean;
|
||||
}) {
|
||||
return <section className="trade-deck-list"><h3>{title}<span>{cards.length}</span></h3>{cards.length ? <div className="trade-card-list">{cards.map(card => {
|
||||
const id = card.block.tokenId;
|
||||
return <label className="trade-card-row" key={id}>
|
||||
<input type="checkbox" checked={selected.has(id)} disabled={disabled} onChange={() => onToggle(id)} />
|
||||
<img src={card.block.article.image} alt="" loading="lazy" />
|
||||
<span><strong>{card.block.article.title}</strong><small>#{card.block.height} · {short(id)}{card.confirmations < CONSENSUS.coinbaseMaturity ? ' · immature' : ''}</small></span>
|
||||
</label>;
|
||||
})}</div> : <p className="muted">No confirmed cards in this deck.</p>}</section>;
|
||||
}
|
||||
|
||||
export function TradeDesk({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
|
||||
const [address, setAddress] = useState('');
|
||||
const [remoteDeck, setRemoteDeck] = useState<TestnetDeck>();
|
||||
const [offered, setOffered] = useState<Set<string>>(new Set());
|
||||
const [requested, setRequested] = useState<Set<string>>(new Set());
|
||||
const [offerText, setOfferText] = useState('');
|
||||
const [offerError, setOfferError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loadingDeck, setLoadingDeck] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const parsedOffer = useMemo<TradeOffer | undefined>(() => {
|
||||
try { return tradeOfferSchema.parse(JSON.parse(offerText)); } catch { return undefined; }
|
||||
}, [offerText]);
|
||||
const offerTitle = (id: string) => snapshot?.blocks.find(block => block.tokenId === id)?.article.title ?? short(id);
|
||||
|
||||
const myDeck = useMemo<OwnedCard[]>(() => {
|
||||
if (!wallet || !snapshot) return [];
|
||||
return snapshot.blocks.filter(block => ownsFrom(snapshot, block.tokenId, wallet.address)).map(block => ({
|
||||
block, sequence: ownershipSequence(snapshot, block.tokenId),
|
||||
confirmations: snapshot.blocks.length - block.height + 1,
|
||||
}));
|
||||
}, [snapshot, wallet]);
|
||||
|
||||
const toggle = (setter: (value: Set<string>) => void, value: Set<string>, id: string) => {
|
||||
const next = new Set(value);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setter(next);
|
||||
};
|
||||
|
||||
const loadDeck = async () => {
|
||||
const normalized = address.trim().toLowerCase();
|
||||
if (!addressPattern.test(normalized)) { setOfferError('Enter a 64-character wallet receive address.'); return; }
|
||||
if (wallet?.address === normalized) { setOfferError('Enter another player’s address.'); return; }
|
||||
setLoadingDeck(true); setOfferError(''); setRemoteDeck(undefined); setOffered(new Set()); setRequested(new Set());
|
||||
try {
|
||||
const deck = await api<TestnetDeck>(`decks/${normalized}?offset=0&limit=24`);
|
||||
if (deck.network !== NETWORK || deck.address !== normalized || !Array.isArray(deck.nfts)) throw new Error('The node returned an incompatible deck.');
|
||||
setRemoteDeck(deck);
|
||||
onNotice(`Loaded ${deck.nfts.length} of ${deck.total} confirmed cards at chain tip ${short(deck.tip)}.`);
|
||||
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not load that deck.'); }
|
||||
finally { setLoadingDeck(false); }
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!remoteDeck || remoteDeck.next === null || loadingMore) return;
|
||||
setLoadingMore(true); setOfferError('');
|
||||
try {
|
||||
const page = await api<TestnetDeck>(`decks/${remoteDeck.address}?offset=${remoteDeck.next}&limit=${remoteDeck.limit}`);
|
||||
if (page.chainId !== remoteDeck.chainId || page.tip !== remoteDeck.tip || page.offset !== remoteDeck.next) {
|
||||
throw new Error('The deck changed while paging. Reload it before continuing.');
|
||||
}
|
||||
setRemoteDeck(current => current && current.tip === page.tip
|
||||
? { ...current, nfts: [...current.nfts, ...page.nfts], next: page.next }
|
||||
: current);
|
||||
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not load the next deck page.'); }
|
||||
finally { setLoadingMore(false); }
|
||||
};
|
||||
|
||||
const createOffer = async () => {
|
||||
if (!wallet || !snapshot || !remoteDeck || offered.size < 1 || requested.size < 1) return;
|
||||
if (offered.size > CONSENSUS.maxTradeCards || requested.size > CONSENSUS.maxTradeCards) {
|
||||
setOfferError(`A trade can include at most ${CONSENSUS.maxTradeCards} cards per side.`); return;
|
||||
}
|
||||
setBusy(true); setOfferError('');
|
||||
try {
|
||||
const fresh = await api<TestnetSnapshot>('state');
|
||||
if (fresh.chainId !== snapshot.chainId || remoteDeck.chainId !== fresh.chainId) throw new Error('Chain changed. Refresh and rebuild the trade.');
|
||||
const offeredLegs = [...offered].map(cardHash => {
|
||||
const card = myDeck.find(item => item.block.tokenId === cardHash);
|
||||
if (!card || !ownsFrom(fresh, cardHash, wallet.address)) throw new Error('One of your selected cards is no longer yours.');
|
||||
if (fresh.blocks.length + 1 - card.block.height < CONSENSUS.coinbaseMaturity) throw new Error(`${card.block.article.title} is not mature enough to trade.`);
|
||||
if (fresh.pendingTransfers.some(tx => (isNftTrade(tx)
|
||||
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === cardHash)
|
||||
: tx.cardHash === cardHash))) throw new Error(`${card.block.article.title} already has a pending transfer or trade.`);
|
||||
return { cardHash, sequence: ownershipSequence(fresh, cardHash) };
|
||||
});
|
||||
const requestedLegs = [...requested].map(cardHash => {
|
||||
const card = remoteDeck.nfts.find(item => item.block.tokenId === cardHash);
|
||||
if (!card || !ownsFrom(fresh, cardHash, remoteDeck.address)) throw new Error('The other player no longer owns one of the selected cards.');
|
||||
if (fresh.blocks.length + 1 - card.block.height < CONSENSUS.coinbaseMaturity) throw new Error(`${card.block.article.title} is not mature enough to trade.`);
|
||||
if (fresh.pendingTransfers.some(tx => (isNftTrade(tx)
|
||||
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === cardHash)
|
||||
: tx.cardHash === cardHash))) throw new Error(`${card.block.article.title} already has a pending transfer or trade.`);
|
||||
return { cardHash, sequence: ownershipSequence(fresh, cardHash) };
|
||||
});
|
||||
const terms = { network: NETWORK, chainId: fresh.chainId, proposer: wallet.address,
|
||||
counterparty: remoteDeck.address, offered: offeredLegs, requested: requestedLegs };
|
||||
const proposerSignature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, wallet.privateKey,
|
||||
new TextEncoder().encode(nftTradeMessage(terms)));
|
||||
const signatureText = btoa(String.fromCharCode(...new Uint8Array(proposerSignature)));
|
||||
const offer = { format: 'wikideck-trade-offer' as const, version: 1 as const, ...terms,
|
||||
proposerPublicKey: wallet.publicKey, proposerSignature: signatureText };
|
||||
setOfferText(JSON.stringify(offer, null, 2));
|
||||
onNotice('Signed offer prepared. Share its JSON with the other player; nothing has been submitted yet.');
|
||||
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not create trade offer.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const acceptOffer = async () => {
|
||||
if (!wallet || !snapshot || busy) return;
|
||||
setBusy(true); setOfferError('');
|
||||
try {
|
||||
if (offerText.length > 16_384) throw new Error('Trade offer JSON is too large.');
|
||||
const offer: TradeOffer = tradeOfferSchema.parse(JSON.parse(offerText));
|
||||
if (offer.network !== NETWORK || offer.chainId !== snapshot.chainId) throw new Error('Offer is for another network or chain.');
|
||||
if (offer.counterparty !== wallet.address) throw new Error('This offer is addressed to a different wallet.');
|
||||
if (!await verifyOfferSignature(offer)) throw new Error('The proposer signature is invalid; do not accept this offer.');
|
||||
const fresh = await api<TestnetSnapshot>('state');
|
||||
if (fresh.chainId !== offer.chainId) throw new Error('The chain changed. Reload and review the offer again.');
|
||||
for (const leg of offer.offered) {
|
||||
const block = fresh.blocks.find(item => item.tokenId === leg.cardHash);
|
||||
if (!block || !ownsFrom(fresh, leg.cardHash, offer.proposer) || ownershipSequence(fresh, leg.cardHash) !== leg.sequence ||
|
||||
fresh.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity) throw new Error('An offered card is no longer owned, current, or mature.');
|
||||
}
|
||||
for (const leg of offer.requested) {
|
||||
const block = fresh.blocks.find(item => item.tokenId === leg.cardHash);
|
||||
if (!block || !ownsFrom(fresh, leg.cardHash, wallet.address) || ownershipSequence(fresh, leg.cardHash) !== leg.sequence ||
|
||||
fresh.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity) throw new Error('One of your requested cards is no longer owned, current, or mature.');
|
||||
}
|
||||
const counterpartySignature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, wallet.privateKey,
|
||||
new TextEncoder().encode(nftTradeMessage(offer)));
|
||||
const accepted: NftTrade = { type: 'trade', network: offer.network, chainId: offer.chainId,
|
||||
proposer: offer.proposer, counterparty: offer.counterparty, offered: offer.offered, requested: offer.requested,
|
||||
proposerPublicKey: offer.proposerPublicKey, proposerSignature: offer.proposerSignature,
|
||||
counterpartyPublicKey: wallet.publicKey,
|
||||
counterpartySignature: btoa(String.fromCharCode(...new Uint8Array(counterpartySignature))) };
|
||||
const result = await api<{ status: string; message: string }>('trades', accepted);
|
||||
onNotice(result.message);
|
||||
setOfferText(''); setRemoteDeck(undefined); setOffered(new Set()); setRequested(new Set());
|
||||
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not accept this trade offer.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const copyOffer = async () => {
|
||||
try { await navigator.clipboard.writeText(offerText); onNotice('Signed trade offer copied. Share it privately with the other player.'); }
|
||||
catch { onNotice('Clipboard unavailable. Select and copy the offer JSON.'); }
|
||||
};
|
||||
|
||||
const mineEligible = myDeck.filter(card => card.confirmations >= CONSENSUS.coinbaseMaturity &&
|
||||
!snapshot?.pendingTransfers.some(tx => (isNftTrade(tx)
|
||||
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === card.block.tokenId)
|
||||
: tx.cardHash === card.block.tokenId)));
|
||||
const otherEligible = (remoteDeck?.nfts ?? []).filter(card => card.confirmations >= CONSENSUS.coinbaseMaturity &&
|
||||
!snapshot?.pendingTransfers.some(tx => (isNftTrade(tx)
|
||||
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === card.block.tokenId)
|
||||
: tx.cardHash === card.block.tokenId)));
|
||||
|
||||
return <section className="trade-desk">
|
||||
<header className="trade-heading"><div><div className="section-eyebrow">PLAYER-TO-PLAYER · ATOMIC SETTLEMENT</div><h1>Trade decks</h1><p>Browse a public wallet deck, choose multiple cards on both sides, then exchange a signed offer. The complete trade settles as one indivisible on-chain transaction.</p></div><span className="trade-limit">Up to {CONSENSUS.maxTradeCards} cards per side</span></header>
|
||||
{!wallet && <p className="trade-warning">Open or restore your wallet to build and accept trade offers.</p>}
|
||||
{!rpcAvailable && <p className="trade-warning">Node is unavailable. Reconnect before creating or accepting a trade.</p>}
|
||||
<section className="trade-lookup"><label htmlFor="player-address">PLAYER RECEIVE ADDRESS</label><div><input id="player-address" value={address} onChange={event => setAddress(event.target.value)} placeholder="Paste a 64-character wallet address" maxLength={64} autoComplete="off"/><button className="button dark" onClick={loadDeck} disabled={loadingDeck || !rpcAvailable}>{loadingDeck?<LoaderCircle className="spin" size={16}/>:<RefreshCw size={16}/>}Load deck</button></div><p>Decks are public canonical ownership at the node’s reported chain tip. Viewing an address can reveal its holdings.</p></section>
|
||||
<div className="trade-columns">
|
||||
<DeckList title="Your confirmed cards · offered" cards={mineEligible} selected={offered} onToggle={id => toggle(setOffered, offered, id)} disabled={!wallet || busy}/>
|
||||
<div className="trade-arrow"><ArrowLeftRight size={24}/><span>Swap</span></div>
|
||||
<DeckList title={remoteDeck?`Player ${short(remoteDeck.address)} · requested`:'Other player’s deck'} cards={otherEligible} selected={requested} onToggle={id => toggle(setRequested, requested, id)} disabled={busy || !remoteDeck}/>
|
||||
</div>
|
||||
<div className="trade-actions"><button className="button dark" onClick={createOffer} disabled={!wallet || !rpcAvailable || !remoteDeck || !offered.size || !requested.size || busy}><Check size={16}/>Sign offer</button><span>{offered.size} offered · {requested.size} requested</span></div>
|
||||
<section className="trade-import"><div className="section-eyebrow">OFFER JSON · COPY OR PASTE</div><p>Only the proposing wallet signs when creating an offer. It is not active until the addressed player reviews and accepts it. Acceptance signs all requested cards and submits one atomic trade bundle; stale offers are rejected.</p><textarea aria-label="Trade offer JSON" value={offerText} onChange={event => setOfferText(event.target.value)} placeholder="Create an offer above, or paste a trade offer from another player." spellCheck={false}/>{parsedOffer&&<div className="trade-review"><strong>Review before signing</strong><p>{short(parsedOffer.proposer)} offers {parsedOffer.offered.map(leg=>offerTitle(leg.cardHash)).join(', ')} to {short(parsedOffer.counterparty)} for {parsedOffer.requested.map(leg=>offerTitle(leg.cardHash)).join(', ')}.</p><small>Offer signatures are checked by the node. Acceptance transfers all listed cards together or none.</small></div>}{offerError&&<p className="trade-error" role="alert">{offerError}</p>}<div className="trade-actions"><button className="button outline" onClick={copyOffer} disabled={!offerText}><Clipboard size={16}/>Copy offer</button><button className="button dark" onClick={acceptOffer} disabled={!wallet || !rpcAvailable || !parsedOffer || busy}>{busy?<LoaderCircle className="spin" size={16}/>:<Send size={16}/>}Accept and submit atomic trade</button></div>
|
||||
{remoteDeck&&<div className="trade-tip">Deck tip: <code>{short(remoteDeck.tip)}</code> · showing {remoteDeck.nfts.length} of {remoteDeck.total}; ownership and maturity re-checked when signing.{remoteDeck.next!==null&&<button className="text-button" onClick={loadMore} disabled={loadingMore}>{loadingMore?<LoaderCircle className="spin" size={14}/>:null}Load more cards</button>}</div>}
|
||||
</section>
|
||||
</section>;
|
||||
}
|
||||
+1
-1
@@ -83,7 +83,7 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
|
||||
|
||||
return <section aria-labelledby={`${id}-title`} aria-busy={busy}>
|
||||
<h2 id={`${id}-title`}>Wallet backup & recovery</h2>
|
||||
<p>This is a recoverable P-256 wallet for wikideck-randomx-testnet-5, separate from your old demo wallet.</p>
|
||||
<p>This is a recoverable P-256 wallet for wikideck-randomx-testnet-6, 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>
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
.trade-desk{max-width:1180px;margin:0 auto}.trade-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin:24px 0 28px}.trade-heading h1{font-size:32px;letter-spacing:-1px;margin:8px 0}.trade-heading p,.trade-lookup p,.trade-import>p{max-width:760px;color:var(--ctp-subtext0);font-size:12px;line-height:1.8}.trade-limit{white-space:nowrap;padding:9px 12px;border:1px solid var(--theme-border);border-radius:8px;color:var(--ctp-lavender);font-size:10px}.trade-warning,.trade-error{padding:12px 14px;border:1px solid #e78284;color:var(--ctp-red);background:color-mix(in srgb,var(--ctp-red) 9%,var(--ctp-base));border-radius:7px;font-size:12px}.trade-error{margin:12px 0}.trade-lookup,.trade-import{padding:18px 20px;margin:18px 0 24px;border:1px solid var(--theme-border);background:var(--ctp-mantle);border-radius:10px}.trade-lookup>label{display:block;margin-bottom:8px;color:var(--ctp-subtext1);font-size:9px;letter-spacing:1px}.trade-lookup>div{display:flex;gap:10px}.trade-lookup input,.trade-import textarea{min-width:0;width:100%;padding:12px;border:1px solid var(--ctp-surface1);border-radius:7px;background:var(--ctp-base);color:var(--ctp-text);font:inherit;font-size:12px}.trade-lookup .button,.trade-actions .button{display:inline-flex;align-items:center;justify-content:center;gap:8px;white-space:nowrap}.trade-lookup p{margin:9px 0 0;font-size:10px}.trade-columns{display:grid;grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr);gap:16px;align-items:start}.trade-deck-list{min-width:0;padding:16px;border:1px solid var(--theme-border);border-radius:10px;background:var(--ctp-mantle)}.trade-deck-list h3{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0 0 12px;font-size:13px;color:var(--ctp-text)}.trade-deck-list h3 span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:6px;background:var(--ctp-surface0);font-size:10px}.trade-card-list{display:grid;gap:7px;max-height:440px;overflow:auto}.trade-card-row{display:grid;grid-template-columns:16px 46px minmax(0,1fr);align-items:center;gap:10px;min-height:58px;padding:6px 8px;border:1px solid transparent;border-radius:7px;background:var(--ctp-base);cursor:pointer}.trade-card-row:has(input:checked){border-color:var(--theme-accent);background:var(--theme-accent-soft)}.trade-card-row input{accent-color:var(--theme-accent)}.trade-card-row img{width:46px;height:42px;object-fit:cover;border-radius:5px;background:var(--ctp-surface0)}.trade-card-row span{min-width:0}.trade-card-row strong,.trade-card-row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trade-card-row strong{font-size:10px;color:var(--ctp-text)}.trade-card-row small{margin-top:4px;font-size:8px;color:var(--ctp-subtext0)}.trade-arrow{display:grid;justify-items:center;gap:5px;margin-top:80px;color:var(--ctp-peach)}.trade-arrow span{font-size:8px;letter-spacing:.6px;text-transform:uppercase}.trade-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:16px 0}.trade-actions>span{color:var(--ctp-subtext0);font-size:10px}.trade-import textarea{display:block;min-height:150px;margin-top:12px;resize:vertical;font-family:ui-monospace,monospace;font-size:10px}.trade-review{margin-top:12px;padding:12px;border:1px solid var(--theme-border);border-radius:7px;background:var(--ctp-base);font-size:11px}.trade-review strong{color:var(--ctp-peach)}.trade-review p{margin:7px 0;color:var(--ctp-text);line-height:1.7}.trade-review small{color:var(--ctp-subtext0);line-height:1.6}.trade-tip{display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--ctp-subtext0);font-size:9px}.trade-tip code{color:var(--ctp-text)}.trade-tip .text-button{font-size:10px}.trade-lookup input:focus-visible,.trade-import textarea:focus-visible,.trade-card-row input:focus-visible{outline:2px solid var(--theme-accent);outline-offset:2px}
|
||||
@media(max-width:900px){.trade-columns{grid-template-columns:minmax(0,1fr)}.trade-arrow{display:flex;justify-content:center;align-items:center;margin:0;transform:rotate(90deg)}.trade-arrow span{display:none}}
|
||||
@media(max-width:560px){.trade-heading{display:block}.trade-heading h1{font-size:26px}.trade-limit{display:inline-block;margin-top:8px}.trade-lookup,.trade-import{padding:14px}.trade-lookup>div{display:grid}.trade-lookup .button{width:100%}.trade-card-list{max-height:360px}.trade-actions .button{flex:1;font-size:10px}.trade-review{font-size:10px}}
|
||||
Reference in New Issue
Block a user