Remove markets

This commit is contained in:
2026-10-28 18:33:52 -04:00
parent eea2deb1fb
commit b3a1179703
141 changed files with 554 additions and 17365 deletions
+3 -3
View File
@@ -11,7 +11,7 @@ import { WalletPanel } from './WalletPanel.tsx';
import { useModalFocus } from './useModalFocus.ts';
import { ThemePicker } from './ThemePicker.tsx';
import { TradeDesk } from './TradeDesk.tsx';
import { IS_MAINNET, IS_PILOT, NETWORK_LABEL } from '../shared/network.ts';
import { IS_MAINNET, NETWORK_LABEL } from '../shared/network.ts';
import { StateClient } from '../shared/rpc-client.ts';
import './App.css';
import './testnet.css';
@@ -172,7 +172,7 @@ export default function App() {
<div className="main-shell">
<header className="topbar"><div className="breadcrumbs">Explore <ChevronRight size={13} /><span>{view}</span></div><div className="top-actions"><span className="testnet-brand">RandomX <b>{NETWORK_LABEL}</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>{IS_MAINNET?'Gated mainnet candidate · unaudited · mock payments only':IS_PILOT?'Private pilot · mock custody only · no real XMR':'Unbootstrapped testnet · no public mainnet'}</span>{snapshot && !rpcAvailable && <strong>Showing last known state</strong>}</div>
<div className="testnet-status" role="status"><span><span className={`network-dot ${rpcAvailable ? '' : 'disconnected'}`} /> RPC {rpcAvailable ? 'available' : 'unavailable'}</span><span>{peerLabel}</span><span>{IS_MAINNET?'Gated mainnet candidate · unaudited':'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">
@@ -220,7 +220,7 @@ export default function App() {
<div className="guide-steps">
<div><span>01</span><div><h3>Mine with a CPU. Discover a card.</h3><p>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.</p><details><summary>Mining, rewards &amp; network facts</summary><p>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.</p></details></div></div>
<div><span>02</span><div><h3>Keep your collection in your wallet.</h3><p>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.</p><details><summary>Wallet and ownership basics</summary><p>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.</p></details></div></div>
<div><span>03</span><div><h3>Trade cards directly with another collector.</h3><p>Both players sign the same card list; the complete swap is applied together when mined or rejected. Offers are shared between players and are not marketplace listings or sales.</p><details><summary>What happens when I accept?</summary><p>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.</p></details></div></div>
<div><span>03</span><div><h3>Trade cards directly with another collector.</h3><p>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.</p><details><summary>What happens when I accept?</summary><p>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.</p></details></div></div>
</div>
<div className="info-box"><strong>Collectible snapshots—not ownership of Wikipedia.</strong><p>Cards do not grant rights to Wikipedia text or images. Check each card’s attribution and licenses. This is an experimental testnet; cards have no promised monetary value.</p></div>
<button className="button dark full" onClick={() => { close(); goMine(); }}>Explore RandomX mining <ArrowRight size={16}/></button>
-169
View File
@@ -1,169 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { NETWORK, CONSENSUS, testnetOwnerOf, transactionCardIds, type TestnetSnapshot } from '../shared/testnet.ts';
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyActivity,
type CustodyCommand, type CustodyPage } from '../shared/custody.ts';
import { formatXmr, parseXmr } from '../shared/xmr-amount.ts';
import { sign, type Wallet } from './wallet.ts';
import './custody.css';
type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean };
const short = (s: string) => `${s.slice(0, 8)}…${s.slice(-5)}`;
// Aggregate balances have a wider bound than individual sale amounts; format without Number coercion.
const display = (value: string) => { const n = BigInt(value); return `${n / 1_000_000_000_000n}.${(n % 1_000_000_000_000n).toString().padStart(12, '0')}`; };
async function request<T>(command?: CustodyCommand, offset = 0): Promise<T> {
const response = await fetch(command ? '/api/custody/command' : `/api/custody?offset=${offset}`, {
cache: 'no-store', signal: AbortSignal.timeout(15000), ...(command ? {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command),
} : {}),
});
if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Custody API unavailable.');
const data = await response.json();
if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Custody request failed.');
return data;
}
export function CustodyPanel({ wallet, snapshot, rpcAvailable }: Props) {
const [page, setPage] = useState<CustodyPage>();
const [activity, setActivity] = useState<CustodyActivity>();
const [enabled, setEnabled] = useState<boolean>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [review, setReview] = useState<{ action: CustodyAction; description: string }>();
const [retry, setRetry] = useState<CustodyCommand>();
const [card, setCard] = useState('');
const [price, setPrice] = useState('0.100000000000');
const [withdraw, setWithdraw] = useState('0.100000000000');
const [maxFee, setMaxFee] = useState('0.001000000000');
const generation = useRef(0), pending = useRef(false);
const reviewElement = useRef<HTMLElement>(null);
const signed = useCallback(async (action: CustodyAction, current: CustodyPage): Promise<CustodyCommand> => {
if (!wallet) throw new Error('Open your wallet first.');
const c: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK,
chainId: current.info.chainId, custodianId: current.info.custodianId, actor: wallet.address,
publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action };
return { ...c, signature: await sign(wallet, custodyMessage(c)) };
}, [wallet]);
const refresh = useCallback(async (offset = 0) => {
const epoch = generation.current;
const data = await request<CustodyPage | { enabled: false }>(undefined, offset);
if (epoch !== generation.current) return;
if ('enabled' in data) { setEnabled(false); setPage(undefined); setActivity(undefined); return; }
if (data.info.mode !== 'sandbox' || data.info.policy !== CUSTODY_POLICY || data.info.network !== NETWORK ||
data.info.liveDeposits !== false || data.info.liveWithdrawals !== false || data.info.nftSettlement !== false ||
(snapshot && data.info.chainId !== snapshot.chainId)) throw new Error('Incompatible custody policy or network.');
const command = wallet ? await signed({ type: 'activity', offset }, data) : undefined;
if (epoch !== generation.current) return;
const mine = command ? await request<CustodyActivity>(command) : undefined;
if (epoch !== generation.current) return;
setEnabled(true); setPage(data); setActivity(mine);
}, [wallet, signed, snapshot?.chainId]);
useEffect(() => {
generation.current++; setPage(undefined); setActivity(undefined); setReview(undefined); setRetry(undefined); setError(''); setBusy(false);
if (rpcAvailable) void refresh().catch(e => setError(e instanceof Error ? e.message : 'Custody unavailable.'));
return () => { generation.current++; };
}, [refresh, rpcAvailable]);
useEffect(() => { if (review) reviewElement.current?.focus(); }, [review]);
const prepare = (action: CustodyAction, description: string) => {
if (['list','reserve','mock-credit'].includes(action.type) && (page?.info.marketEnabled === false || page?.info.tradingEnabled === false)) {
setError('This host is not accepting new trading or funding. Existing balances and cancellation/withdrawal requests remain accessible.'); return;
}
if (action.type === 'list' || action.type === 'reserve') {
const listingId = action.type === 'reserve' ? action.listingId : undefined;
const recipient = action.type === 'reserve'
? page?.listings.find(l => l.id === listingId)?.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address
: page?.info.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address;
action = { ...action, hostFeeAddress: recipient };
description += ` Host fee recipient: ${recipient}. The separate fixed author fee is unchanged.`;
}
setError(''); setNotice(''); setReview({ action, description });
};
const submit = async (again?: CustodyCommand) => {
if (!wallet || !page || pending.current || (!review && !again)) return;
pending.current = true; setBusy(true); setError('');
const epoch = generation.current;
let command: CustodyCommand | undefined;
try {
command = again ?? await signed(review!.action, page);
if (epoch !== generation.current) return;
setRetry(command); // Retain the EXACT envelope after ambiguous network outcomes; do not mint another debit nonce.
await request(command);
if (epoch !== generation.current) return;
setReview(undefined); setRetry(undefined); setNotice('Sandbox command recorded. No XMR or NFT was transferred.');
await refresh();
} catch (e) {
if (epoch === generation.current) setError(`${e instanceof Error ? e.message : 'Custody request failed.'}${command ? ' Refresh activity before making another request; retry uses the original signed command.' : ''}`);
} finally { pending.current = false; if (epoch === generation.current) setBusy(false); }
};
const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address &&
snapshot.blocks.length - b.height + 1 >= CONSENSUS.coinbaseMaturity &&
!snapshot.pendingTransfers.some(t => transactionCardIds(t).includes(b.tokenId))) ?? [];
const disabled = busy || !wallet || !rpcAvailable || !page || !!retry;
return <section className="custody-panel" aria-label="Custodial marketplace sandbox">
<div className="market-simulation" role="note"><div><strong>Custodial ledger preview · fictitious balances only</strong>
<p>Test funding, fixed-price reservations and withdrawal holds. No deposit address, Monero access, NFT lock or card delivery is enabled. Existing mock checkout is separate. Funds shown here have no monetary value.</p></div></div>
<h2>A balance for this market.</h2>
<p>A future custodian would hold pooled XMR and track what each customer is owed. Purchases would move ledger balances, not XMR between subaddresses. You would trust the operator with your deposits.</p>
{enabled === false && <p className="market-reserved">This host has not enabled the custody sandbox. Real custody is unavailable.</p>}
{page&&(page.info.marketEnabled===false||page.info.tradingEnabled===false)&&<p role="status">{page.info.marketEnabled===false?'Public market disabled on this host.':'New trading paused on this host.'} Existing balances, cancellations and withdrawal requests remain accessible.</p>}
{page?.info.hostFeeAddress&&<p className="custody-identity">Host fee wallet (not a deposit address): {page.info.hostFeeAddress}</p>}
{!rpcAvailable && <p className="market-error">Reconnect to the node to use this preview.</p>}
{!wallet && <p>Open your Wikideck wallet to view private balances and authorize sandbox commands.</p>}
{page && <p className="muted custody-identity">Sandbox custodian {page.info.custodianId} · {page.info.network} · host {page.info.feeBps / 100}% + author 1%. Fresh custody signatures are required; legacy mock authorizations do not debit balances.</p>}
{error && <p role="alert" className="market-error">{error}</p>}
{notice && <p role="status">{notice}</p>}
<div className="market-actions"><button className="button outline" disabled={busy || !rpcAvailable} onClick={() => { setError(''); void refresh().catch(e => setError(String(e.message))); }}>Refresh balances</button>
{retry && <><button className="button outline" disabled={busy || Date.now() > retry.expiresAt} onClick={() => void submit(retry)}>Retry original signature</button>
<button className="button outline" disabled={busy} onClick={() => { setRetry(undefined); setReview(undefined); setNotice('Check refreshed activity before creating a new request. The previous command may already have executed.'); }}>Dismiss after checking activity</button></>}</div>
{activity && <>
<div className="custody-balances">{[
['Available', activity.availableAtomicXmr], ['Purchase reserved', activity.purchaseReservedAtomicXmr],
['Withdrawal reserved', activity.withdrawalReservedAtomicXmr],
].map(([label, value]) => <div key={label}><small>{label}</small><strong>{display(value)} XMR</strong><span>SIMULATED</span></div>)}</div>
<button className="button outline" disabled={disabled || activity.mockCreditClaimed} onClick={() => prepare({ type: 'mock-credit' }, 'Claim the one-time 1 XMR fictitious sandbox credit. This is not a deposit and cannot be withdrawn for real money.')}>{activity.mockCreditClaimed ? 'Sandbox credit claimed' : 'Review 1 XMR test credit'}</button>
</>}
{review && <section className="market-review" ref={reviewElement} tabIndex={-1} aria-label="Review custody signature">
<h3>Review sandbox authorization</h3><p>{review.description}</p><div className="market-actions"><button className="button dark" disabled={busy || !!retry} onClick={() => void submit()}>Sign sandbox command</button>
<button className="button outline" disabled={busy} onClick={() => setReview(undefined)}>Back</button></div></section>}
{page && <>
<div className="custody-forms">
<form onSubmit={e => { e.preventDefault(); try {
if (!snapshot) throw new Error('Chain snapshot unavailable.');
const priceAtomicXmr = parseXmr(price).toString();
if (BigInt(priceAtomicXmr) === 0n) throw new Error('Use a positive price.');
prepare({ type: 'list', cardHash: card, sequence: snapshot.transfers.filter(t => transactionCardIds(t).includes(card)).length,
priceAtomicXmr, feeBps: page.info.feeBps }, `List this card for ${formatXmr(BigInt(priceAtomicXmr))} fictitious XMR. Host ${page.info.feeBps / 100}% plus author 1% are deducted from gross if settlement is implemented. This preview does not lock or transfer the card.`);
} catch (e) { setError(e instanceof Error ? e.message : 'Invalid listing.'); } }}>
<h3>Fixed-price custody listing</h3><label htmlFor="custody-card">Your mature card</label><select id="custody-card" required value={card} onChange={e => setCard(e.target.value)}><option value="">Choose a card…</option>{owned.map(b => <option key={b.tokenId} value={b.tokenId}>{b.article.title}</option>)}</select>
<label htmlFor="custody-price">Price · XMR</label><input id="custody-price" inputMode="decimal" required value={price} onChange={e => setPrice(e.target.value)}/>
<button className="button dark" disabled={disabled || !card}>Review listing</button>
</form>
<form onSubmit={e => { e.preventDefault(); try {
const amountAtomicXmr = parseXmr(withdraw).toString(), maxFeeAtomicXmr = parseXmr(maxFee).toString();
if (BigInt(amountAtomicXmr) === 0n) throw new Error('Use a positive withdrawal amount.');
prepare({ type: 'withdraw', destination: 'mock-external', amountAtomicXmr, maxFeeAtomicXmr }, `Reserve ${withdraw} fictitious XMR plus up to ${maxFee} XMR fee allowance for a MOCK destination. No address is accepted, no XMR is sent, and this hold remains cancellable until prepared by an offline test fixture.`);
} catch (e) { setError(e instanceof Error ? e.message : 'Invalid withdrawal.'); } }}>
<h3>Withdrawal reservation preview</h3><label htmlFor="custody-withdraw">Recipient amount · XMR</label><input id="custody-withdraw" inputMode="decimal" required value={withdraw} onChange={e => setWithdraw(e.target.value)}/>
<label htmlFor="custody-fee">Maximum network fee · XMR</label><input id="custody-fee" inputMode="decimal" required value={maxFee} onChange={e => setMaxFee(e.target.value)}/>
<button className="button outline" disabled={disabled}>Review simulated hold</button>
</form>
</div>
<h3>Custody listings · no auctions</h3>
<div className="custody-records">{page.listings.map(l => <article key={l.id}><h4>{l.title}</h4><p>{display(l.priceAtomicXmr)} XMR · {l.status} · seller {short(l.seller)}</p>
{l.status === 'open' && (l.seller === wallet?.address
? <button className="button outline" disabled={disabled} onClick={() => prepare({ type: 'cancel-listing', listingId: l.id }, 'Cancel this unreserved custody sandbox listing?')}>Cancel listing</button>
: <button className="button dark" disabled={disabled} onClick={() => prepare({ type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: l.feeBps }, `Reserve ${display(l.priceAtomicXmr)} fictitious XMR for ${l.title}. Host ${l.feeBps / 100}% + author 1% deducted from seller proceeds. No purchase network fee. The order will wait for unimplemented NFT locks; no card is delivered. You can cancel this unstarted sandbox reservation.`)}>Review funded reservation</button>)}</article>)}</div>
{page.next !== null && <button className="button outline" disabled={busy} onClick={() => void refresh(page.next!).catch(e => setError(String(e.message)))}>Next listings page</button>}
</>}
{activity && <div className="custody-records"><h3>Your custody activity</h3>
{activity.orders.map(o => <article key={o.id}><strong>Order {short(o.id)}</strong><p>{display(o.grossAtomicXmr)} XMR · {o.status} · {o.buyer === wallet?.address ? 'Buyer' : 'Seller'}</p>
{o.status === 'awaiting-lock' && <p>Blocked: NFT consensus lock and settlement are not implemented. No delivery signature was issued.</p>}
{o.buyer === wallet?.address && o.status === 'awaiting-lock' && <button className="button outline" disabled={disabled} onClick={() => prepare({ type: 'cancel-reservation', orderId: o.id }, 'Cancel this unstarted sandbox order and return its reserved fictitious balance? No NFT intent has executed.')}>Cancel reservation</button>}</article>)}
{activity.withdrawals.map(w => <article key={w.id}><strong>Mock withdrawal {short(w.id)}</strong><p>{display(w.amountAtomicXmr)} XMR + max {display(w.maxFeeAtomicXmr)} fee · {w.status}</p>
{w.status === 'reserved' && <button className="button outline" disabled={disabled} onClick={() => prepare({ type: 'cancel-withdrawal', withdrawalId: w.id }, 'Cancel the unprepared mock withdrawal and release the full amount and maximum fee allowance?')}>Cancel withdrawal hold</button>}</article>)}
{activity.next !== null && <button className="button outline" disabled={busy} onClick={() => void refresh(activity.next!).catch(e => setError(String(e.message)))}>Next activity page</button>}
</div>}
<p className="muted custody-identity">Author policy recipient (not a deposit address): {ADDITIONAL_MARKET_FEE.address}. No real funds or commissions are collected.</p>
</section>;
}
-206
View File
@@ -1,206 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Clock3, Gavel, LoaderCircle, RefreshCw, ShoppingBag, Tag } from 'lucide-react';
import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetSnapshot } from '../shared/testnet.ts';
import { ADDITIONAL_MARKET_FEE, formatUsd, marketMessage, type MarketAction, type MarketInfo,
type MarketListing, type MarketOrder } from '../shared/market.ts';
import { atomicXmrString, formatXmr, parseAtomicXmr, parseXmr } from '../shared/xmr-amount.ts';
import { isXmrListing, isXmrOrder, marketMessageV3, type MarketActionV3, type MarketActivityV3,
type MarketCommandV3, type MarketListingRecord, type MarketOrderRecord } from '../shared/market-v3.ts';
import { sign, type Wallet } from './wallet.ts';
import { imageCredit } from './Card.tsx';
import { CustodyPanel } from './CustodyPanel.tsx';
import './market.css';
type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean; onNotice(message: string): void };
type Page = { listings: MarketListingRecord[]; next: number | null; info: MarketInfo };
const short = (v: string) => `${v.slice(0, 8)}…${v.slice(-5)}`;
const feeDisclosure = (marketBps: number) => `Hosting peer fee: ${marketBps / 100}% plus a hardcoded 1% project author fee (${(marketBps + ADDITIONAL_MARKET_FEE.bps) / 100}% total), both deducted from seller proceeds. Author fee recipient: ${ADDITIONAL_MARKET_FEE.address}. Payouts are simulated only.`;
async function api<T>(body?: unknown, offset = 0): Promise<T> {
const response = await fetch(body ? '/api/market/command' : `/api/market?offset=${offset}`, {
cache: 'no-store', signal: AbortSignal.timeout(15000),
...(body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
});
if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Marketplace is unavailable on this node.');
const data = await response.json();
if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Marketplace request failed.');
return data;
}
async function commandV3<T>(wallet: Wallet, info: MarketInfo, action: MarketActionV3): Promise<T> {
const envelope = { network: NETWORK, chainId: info.chainId, marketId: info.marketId, actor: wallet.address,
version: 3 as const, publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action };
return api<T>({ ...envelope, signature: await sign(wallet, marketMessageV3(envelope as Omit<MarketCommandV3, 'signature'>)) });
}
async function commandV2<T>(wallet: Wallet, info: MarketInfo, action: MarketAction): Promise<T> {
const envelope = { network: NETWORK, chainId: info.chainId, marketId: info.marketId, actor: wallet.address,
publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action };
return api<T>({ ...envelope, signature: await sign(wallet, marketMessage(envelope)) });
}
const orderPrice = (order: MarketOrderRecord) => isXmrOrder(order)
? `${formatXmr(parseAtomicXmr(order.priceAtomicXmr))} XMR`
: `${formatUsd(order.priceCents)} USD (legacy)`;
const listingPriceLegacy = (listing: Extract<MarketListingRecord, { priceCents: number }>) =>
`${formatUsd(listing.highestBid?.priceCents ?? listing.priceCents)} USD (legacy)`;
function deadline(time: number, now: number) {
if (time <= now) return 'Closed';
const minutes = Math.ceil((time - now) / 60000);
return minutes >= 1440 ? `${Math.ceil(minutes / 1440)}d remaining` : minutes >= 60 ? `${Math.ceil(minutes / 60)}h remaining` : `${minutes}m remaining`;
}
export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
const [page, setPage] = useState<Page>();
const [activity, setActivity] = useState<MarketActivityV3>({ listings: [], orders: [] });
const [tab, setTab] = useState<'browse' | 'sell' | 'orders' | 'custody'>('browse');
const [filter, setFilter] = useState<'all' | 'fixed' | 'auction'>('all');
const [cardHash, setCardHash] = useState('');
const [kind, setKind] = useState<'fixed' | 'auction'>('fixed');
const [price, setPrice] = useState('0.100000000000');
const [hours, setHours] = useState('24');
const [bids, setBids] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [review, setReview] = useState<{ action: MarketAction | MarketActionV3; text: string; legacy?: boolean }>();
const [now, setNow] = useState(Date.now());
const version = useRef(0);
const inFlight = useRef(false);
const reviewPanel = useRef<HTMLElement>(null);
useEffect(() => {
if (!review) return;
reviewPanel.current?.focus({ preventScroll: true });
reviewPanel.current?.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
}, [review]);
const refresh = useCallback(async () => {
const current = version.current;
const data = await api<Page>();
if (data.info.network !== NETWORK || data.info.marketVersion !== 3 || data.info.paymentMode !== 'mock' ||
data.info.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || data.info.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address ||
(snapshot && data.info.chainId !== snapshot.chainId)) throw new Error('Incompatible marketplace network, payment mode, or fee policy.');
const mine = wallet ? await commandV3<MarketActivityV3>(wallet, data.info, { type: 'activity' }) : { listings: [], orders: [] };
if (current !== version.current) return;
setPage(data); setActivity(mine); setNow(Date.now());
}, [wallet, snapshot?.chainId]);
useEffect(() => {
version.current++; setPage(undefined); setActivity({ listings: [], orders: [] }); setReview(undefined);
let active = true;
const load = () => { void refresh().catch(e => { if (active) setError(e instanceof Error ? e.message : 'Could not load market.'); }); };
if (rpcAvailable) load();
const timer = setInterval(() => { setNow(Date.now()); if (rpcAvailable && !inFlight.current) load(); }, 15000);
return () => { active = false; version.current++; clearInterval(timer); };
}, [refresh, rpcAvailable]);
const prepare = (action: MarketAction | MarketActionV3, text: string, legacy = false) => {
if (['list','bid','buy'].includes(action.type) && (page?.info.marketEnabled === false || page?.info.tradingEnabled === false)) {
setError('This host is not accepting new trading. Existing orders can still be resolved.'); return;
}
if (!legacy && (action.type === 'list' || action.type === 'buy')) {
const listingId = action.type === 'buy' ? action.listingId : undefined;
const recipient = action.type === 'buy'
? page?.listings.find(l => l.id === listingId)?.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address
: page?.info.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address;
action = { ...action, hostFeeAddress: recipient } as MarketActionV3;
text += ` Host fee recipient: ${recipient}. The separate fixed author fee is unchanged.`;
}
setError(''); setReview({ action, text, legacy });
};
const submit = async () => {
if (!wallet || !page || !review || inFlight.current || !rpcAvailable) return;
const currentVersion = version.current;
inFlight.current = true; setBusy(true); setError('');
try {
const action = review.action.type === 'deliver' ? { ...review.action, transfer: { ...review.action.transfer,
signature: await sign(wallet, nftTransferMessage(review.action.transfer)) } } : review.action;
if (review.legacy) await commandV2(wallet, page.info, action as MarketAction);
else await commandV3(wallet, page.info, action as MarketActionV3);
if (currentVersion !== version.current) return;
const type = review.action.type;
setReview(undefined);
if (type === 'buy' || type === 'mock-pay' || type === 'deliver' || type === 'refund') setTab('orders');
onNotice(type === 'deliver' ? 'Delivery submitted. Simulated seller payout waits for canonical confirmations.' : 'Marketplace updated. All XMR payments and payouts are simulations.');
await refresh();
} catch (e) { setError(e instanceof Error ? e.message : 'Marketplace action failed.'); }
finally { inFlight.current = false; setBusy(false); }
};
const listed = new Set(activity.listings.filter(l => l.status === 'open' || l.status === 'reserved').map(l => l.cardHash));
const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address &&
snapshot.blocks.length + 1 - b.height >= CONSENSUS.coinbaseMaturity && !listed.has(b.tokenId) &&
!snapshot.pendingTransfers.some(tx => transactionCardIds(tx).includes(b.tokenId))) ?? [];
const create = () => {
if (!snapshot || !page || !owned.some(b => b.tokenId === cardHash)) { setError('Select an eligible card first.'); return; }
try {
const priceAtomicXmr = atomicXmrString(parseXmr(price)), duration = Number(hours);
if (kind === 'auction' && (!Number.isFinite(duration) || duration < 1 / 60 || duration > 720)) throw new Error('Choose an auction duration from one minute to 30 days.');
prepare({ type: 'list', cardHash, sequence: snapshot.transfers.filter(tx => transactionCardIds(tx).includes(cardHash)).length,
kind, priceAtomicXmr, endsAt: kind === 'auction' ? Date.now() + Math.round(duration * 3600_000) : null, feeBps: page.info.feeBps },
`List ${owned.find(b => b.tokenId === cardHash)!.article.title} ${kind === 'fixed' ? 'for' : 'with a starting bid of'} ${formatXmr(parseAtomicXmr(priceAtomicXmr))} XMR. ${feeDisclosure(page.info.feeBps)} ${kind === 'auction' ? `Auction lasts ${duration} hours. You cannot cancel after the first bid.` : 'The listing stays open until sold or cancelled.'} Listing does not lock the card on-chain.`);
} catch (e) { setError(e instanceof Error ? e.message : 'Invalid price.'); }
};
const deliver = async (o: MarketOrderRecord) => {
if (!wallet || !page) return;
try {
const tx = { network: NETWORK, chainId: page.info.chainId, cardHash: o.cardHash,
from: wallet.address, to: o.buyer, sequence: o.sequence };
const transfer = { ...tx, publicKey: wallet.publicKey, signature: '' }; // Sign only after explicit review confirmation.
prepare({ type: 'deliver', orderId: o.id, transfer },
`Transfer card ${short(o.cardHash)} permanently to ${short(o.buyer)}. Only mock payment has been received. This signature submits a real NFT transfer on the selected network. Agreed sale price: ${orderPrice(o)}. Simulated net proceeds: ${o.sellerNetXmr} XMR, after ${o.feeXmr} XMR market fee${o.additionalFee ? ` and ${o.additionalFee.amountXmr} XMR additional fee (${o.additionalFee.bps / 100}%) to ${o.additionalFee.address}` : ' (legacy order: no additional fee)'}.`, !isXmrOrder(o));
} catch { setError('Could not prepare delivery signature.'); }
};
const loadMore = async () => {
if (!page || page.next === null) return;
try {
const next = await api<Page>(undefined, page.next);
if (next.info.marketId !== page.info.marketId || next.info.chainId !== page.info.chainId) throw new Error('Market changed. Refresh first.');
setPage(current => current ? { ...next, listings: [...new Map([...current.listings, ...next.listings].map(l => [l.id, l])).values()] } : next);
} catch (e) { setError(e instanceof Error ? e.message : 'Could not load listings.'); }
};
const disabled = busy || !wallet || !rpcAvailable || !page;
return <section className="marketplace">
{page&&(page.info.marketEnabled===false||page.info.tradingEnabled===false)&&<p className="market-simulation" role="status">{page.info.marketEnabled===false?'This peer does not offer a public marketplace.':'This peer has paused new trading.'} Existing orders, cancellations and resolution remain accessible through Your activity.</p>}
{page?.info.hostFeeAddress&&<p className="market-fee-recipient">Host fee wallet (public payout recipient, not a deposit address): <span>{page.info.hostFeeAddress}</span></p>}
<header className="market-hero"><div><div className="section-eyebrow">A MARKET RUN BY THIS PEER</div><h1>Find your next card.<br/><span>Trade with your people.</span></h1><p>Browse fixed-price cards or join a timed auction. Prices are shown in XMR. Each peer runs its own market, so listings and orders stay with this host.</p></div><div className="market-symbol"><ShoppingBag size={52}/><span>PEER-HOSTED · COMMUNITY-DRIVEN</span></div></header>
<p className="muted">The host sets its market fee; a separate 1% author fee also applies. Both are taken from simulated seller proceeds and shown before signing. Community curation and custom trading-card games are planned, not available yet.</p>
<div className="market-simulation" role="note"><ShieldNote/><div><strong>Mock payments only · never send real XMR</strong><p>Quotes, payment confirmations, refunds, fees, and automatic seller payouts are simulated. Card delivery is a real on-chain transfer. There is no payable Monero address. Listings are local to this marketplace node, not shared by peers.</p></div><span className="market-mode">SANDBOX</span></div>
<div className="market-toolbar"><div className="market-tabs" role="group" aria-label="Marketplace section">{(['browse','sell','orders','custody'] as const).map(t=><button key={t} className={tab===t?'selected':''} onClick={()=>setTab(t)}>{t==='browse'?'Explore market':t==='sell'?'Sell a card':t==='custody'?'Custody preview':'Your activity'}</button>)}</div><button className="text-button" onClick={()=>{setError('');void refresh().catch(e=>setError(String(e.message)));}} disabled={busy||!rpcAvailable}><RefreshCw size={14}/>Refresh</button></div>
{tab==='custody'&&<CustodyPanel wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable}/>}
{page&&<><div className="market-meta"><span>Host <strong>{page.info.feeBps/100}%</strong> + author <strong>1%</strong> · {(page.info.feeBps+ADDITIONAL_MARKET_FEE.bps)/100}% total deducted from seller</span><span>Delivery <strong>{page.info.deliveryConfirmations} confirmations</strong> before mock payout</span><span>New listings and orders: <strong>XMR-native</strong></span></div><p className="market-fee-recipient">Hardcoded project author fee recipient · simulated payout only, not a checkout address: <span>{ADDITIONAL_MARKET_FEE.address}</span></p></>}
{!rpcAvailable&&<p className="market-error" role="alert">Reconnect to the node before using the marketplace.</p>}
{!wallet&&<p className="muted">Open your local wallet to list cards, bid, and check out.</p>}
{error&&<p className="market-error" role="alert">{error}</p>}
{review&&<section className="market-review" aria-label="Review marketplace signature" ref={reviewPanel} tabIndex={-1}><div className="section-eyebrow">REVIEW BEFORE SIGNING</div><p>{review.text}</p><div className="market-actions"><button className="button dark" onClick={submit} disabled={!!disabled}>{busy?<LoaderCircle size={16} className="spin"/>:<Check size={16}/>}Sign and confirm</button><button className="button outline" onClick={()=>setReview(undefined)} disabled={busy}>Back</button></div></section>}
{tab==='browse'&&<><div className="market-filter" role="group" aria-label="Listing type">{(['all','fixed','auction'] as const).map(f=><button key={f} onClick={()=>setFilter(f)} className={filter===f?'selected':''}>{f==='all'?'All discoveries':f==='fixed'?'Buy now':'Auctions'}</button>)}</div><div className="market-grid">{page?.listings.filter(l=>filter==='all'||l.kind===filter).map(l=>{
const ended=l.kind==='auction'&&now>=l.endsAt!, mine=l.seller===wallet?.address;
const legacy=!isXmrListing(l);
const saleAtomic=isXmrListing(l)?(l.highestBid?.priceAtomicXmr??l.priceAtomicXmr):undefined;
const salePrice=legacy?(l.highestBid?.priceCents??l.priceCents):undefined;
const article=snapshot?.blocks.find(b=>b.tokenId===l.cardHash)?.article;
return <article className="market-card" key={l.id}><div className="market-art"><img src={l.image} alt="" loading="lazy"/><span>{l.kind==='auction'?<Gavel size={12}/>:<Tag size={12}/>} {l.kind==='auction'?'Timed auction':'Fixed price'}</span></div><div className="market-card-body"><small>BY {short(l.seller)} {mine?'· YOU':''} {legacy&&<strong>· USD LEGACY</strong>}</small><h2>{l.title}</h2><div className="market-price"><strong>{legacy?formatUsd(salePrice!):`${formatXmr(parseAtomicXmr(saleAtomic!))} XMR`}</strong><span>{legacy?'Legacy USD terms':l.kind==='fixed'?'Native-XMR price':l.highestBid?'Highest bid':'Starting bid'}</span></div>{legacy&&l.status==='open'&&!mine&&<p className="market-reserved">Legacy USD listing is read-only during the native-XMR market transition.</p>}{l.kind==='auction'&&<p className="market-countdown"><Clock3 size={13}/>{deadline(l.endsAt!,now)}{ended&&l.highestBid?` · winner ${short(l.highestBid.bidder)}`:''}</p>}
{l.status==='reserved'?<p className="market-reserved">Reserved · awaiting settlement</p>:legacy?mine&&['open','invalid'].includes(l.status)&&!l.highestBid?<button className="button outline full" disabled={!!disabled} onClick={()=>prepare({type:'retire-legacy-listing',listingId:l.id},`Close the USD legacy listing for ${l.title}? Its old price/signature remain historical; relisting requires a fresh XMR authorization.`)}>Close legacy listing to relist in XMR</button>:<p className="market-reserved">Read-only USD legacy listing · no new bids or checkout</p>:mine?<button className="button outline full" disabled={!!disabled||!!l.highestBid} onClick={()=>prepare({type:'cancel',listingId:l.id},`Cancel the listing for ${l.title}?`)}>Cancel listing</button>:l.kind==='fixed'||ended?<button className="button dark full" disabled={!!disabled||(ended&&l.highestBid?.bidder!==wallet?.address)} onClick={()=>prepare({type:'buy',listingId:l.id,priceAtomicXmr:saleAtomic!,feeBps:l.feeBps},`Reserve ${l.title} for ${formatXmr(parseAtomicXmr(saleAtomic!))} XMR with simulated checkout. ${feeDisclosure(l.feeBps)}. Complete mock payment within 30 minutes ${ended?'of auction closing':'of reservation'}.`)}><ShoppingBag size={14}/>{ended?'Winner checkout':'Buy now'}</button>:<div className="market-bid"><label className="sr-only" htmlFor={`bid-${l.id}`}>Your XMR bid for {l.title}</label><input id={`bid-${l.id}`} inputMode="decimal" value={bids[l.id]??''} placeholder={formatXmr(parseAtomicXmr(atomicXmrString(l.highestBid?BigInt(l.highestBid.priceAtomicXmr)+1n:BigInt(l.priceAtomicXmr))))} onChange={e=>setBids(v=>({...v,[l.id]:e.target.value}))}/><button className="button dark" disabled={!!disabled} onClick={()=>{try{const priceAtomicXmr=atomicXmrString(parseXmr(bids[l.id]??''));prepare({type:'bid',listingId:l.id,priceAtomicXmr},`Bid ${formatXmr(parseAtomicXmr(priceAtomicXmr))} XMR on ${l.title}. If you win, check out within 30 minutes of closing. Bids are signed but not funded; all payment is simulated.`);}catch(e){setError(e instanceof Error?e.message:'Invalid bid');}}}><Gavel size={14}/>Bid</button></div>}
<p className="market-card-foot">{l.feeBps/100}% host + {(l.additionalFee?.bps ?? 0)/100}% author · deducted from seller · NFT {short(l.cardHash)}</p>{article&&<p className="market-card-foot"><a href={article.url} target="_blank" rel="noreferrer">Wikipedia</a> · <a href={imageCredit(article)} target="_blank" rel="noreferrer">Image & license</a> · <a href={`/api/nfts/${l.cardHash}`} target="_blank" rel="noreferrer">NFT metadata</a></p>}</div></article>;
})}</div>{!page?.listings.length&&<div className="market-empty"><ShoppingBag size={30}/><h2>A discovery worth sharing?</h2><p>The market is waiting for its first listing. Offer a card from your confirmed collection.</p><button className="button dark" onClick={()=>setTab('sell')}>List your first card</button></div>}{page?.next!==null&&page?.next!==undefined&&<button className="button outline" onClick={loadMore}>Load more listings</button>}</>}
{tab==='sell'&&<section className="market-sell"><div><div className="section-eyebrow">MAKE ROOM FOR THE NEXT DISCOVERY</div><h2>Put a card on the market.</h2><p>Choose a fixed XMR price or let collectors bid until the clock runs out. Enter up to 12 decimal places; one atomic unit is the smallest representable positive price. You retain custody of the card and must return to sign delivery after mock payment.</p><p>A listing does not lock a card in consensus. Transfers elsewhere can invalidate it. No cancellation after an auction’s first bid; the winner gets 30 minutes to pay. Seller delivery deadline is 48 hours after mock payment.</p></div><form onSubmit={e=>{e.preventDefault();create();}}><label htmlFor="market-card">YOUR MATURE, UNLISTED CARD</label><select id="market-card" value={cardHash} onChange={e=>setCardHash(e.target.value)} required><option value="">Choose a card…</option>{owned.map(b=><option key={b.tokenId} value={b.tokenId}>{b.article.title} · {short(b.tokenId)}</option>)}</select><label htmlFor="market-kind">SALE FORMAT</label><select id="market-kind" value={kind} onChange={e=>setKind(e.target.value as typeof kind)}><option value="fixed">Fixed price · no expiry</option><option value="auction">Timed auction · simulation only</option></select><label htmlFor="market-price">{kind==='fixed'?'PRICE':'STARTING BID'} · XMR</label><input id="market-price" inputMode="decimal" value={price} onChange={e=>setPrice(e.target.value)} required/><p className="muted">12 decimal places maximum. No USD conversion or minimum-price rule. Fees: {page?(page.info.feeBps/100)+'% host + 1% author':'—'} · rounded independently from gross proceeds.</p>{kind==='auction'&&<><label htmlFor="market-hours">AUCTION LENGTH · HOURS</label><input id="market-hours" type="number" min="0.016667" max="720" step="any" value={hours} onChange={e=>setHours(e.target.value)} required/></>}<button className="button dark" type="submit" disabled={!!disabled||!owned.length}><Tag size={15}/>Review listing</button></form></section>}
{tab==='orders'&&<section className="market-activity">
<h2>Orders & settlement</h2>
<p className="muted">Private order details require your wallet signature. New prices use native XMR amounts; legacy orders retain their original USD terms. No private keys leave this browser. Payments and payouts remain simulated.</p>
{activity.orders.length===0&&<p>No orders yet. Explore the market to find your next discovery.</p>}
{activity.orders.map(o=>{
const buyer=o.buyer===wallet?.address;
return <article className="market-order" key={o.id}>
<header><div><strong>{snapshot?.blocks.find(b=>b.tokenId===o.cardHash)?.article.title??short(o.cardHash)}</strong><small>{buyer?'Buying from':'Selling to'} {short(buyer?o.seller:o.buyer)} · {short(o.id)}</small></div><span className="market-state">{o.status.replaceAll('-',' ')}</span></header>
<dl>
<div><dt>{isXmrOrder(o)?'Sale price':'Sale price · USD legacy'}</dt><dd>{orderPrice(o)}</dd></div>
<div><dt>Mock checkout</dt><dd>{o.invoice.amountXmr} XMR</dd></div>
<div><dt>Hosting peer fee ({o.feeBps/100}%) · simulated</dt><dd>{o.feeXmr} XMR</dd></div>
{o.additionalFee && <div><dt>Project author fee ({o.additionalFee.bps/100}%) · simulated</dt><dd>{o.additionalFee.amountXmr} XMR · {o.additionalFee.payout}<span className="market-fee-recipient">Recipient: {o.additionalFee.address}</span></dd></div>}
<div><dt>Seller net · simulated</dt><dd>{o.sellerNetXmr} XMR</dd></div>
</dl>
<p className="muted">{o.status==='awaiting-payment'?`Pay by ${new Date(o.payBy).toLocaleString()}`:o.status==='paid'?`Seller signs delivery by ${new Date(o.deliverBy).toLocaleString()}`:`Delivery: ${o.deliveryConfirmations}/${o.requiredConfirmations} confirmations · payout: ${o.payout}`}</p>
<div className="market-actions">
{buyer&&o.status==='awaiting-payment'&&<button className="button dark" disabled={!!disabled} onClick={()=>prepare({type:'mock-pay',orderId:o.id},`Simulate receipt of ${o.invoice.amountXmr} XMR for order ${short(o.id)}. This sends no money and is not a real payment confirmation. Sale price: ${orderPrice(o)}.`,!isXmrOrder(o))}>Simulate XMR payment</button>}
{!buyer&&(o.status==='paid'||o.status==='delivery-pending')&&<button className="button dark" disabled={!!disabled} onClick={()=>void deliver(o)}>Review & sign card delivery</button>}
{buyer&&o.status==='refund-required'&&<button className="button outline" disabled={!!disabled} onClick={()=>prepare({type:'refund',orderId:o.id},'Simulate a full buyer refund after failed delivery. No real funds will move.',!isXmrOrder(o))}>Simulate full refund</button>}
</div>
{o.status==='review'&&<p className="market-error">A chain reorganization affected settled delivery. Operator review required; no second payout is submitted.</p>}
</article>;
})}
<h2>Your listing history</h2>{activity.listings.map(l=><div className="market-history" key={l.id}><strong>{l.title}</strong><span>{isXmrListing(l)?`${formatXmr(parseAtomicXmr(l.highestBid?.priceAtomicXmr??l.priceAtomicXmr))} XMR`:listingPriceLegacy(l)}</span><span>{l.status}</span></div>)}
</section>}
</section>;
}
function ShieldNote() { return <span className="market-note-icon"><Clock3 size={20}/></span>; }
-16
View File
@@ -1,16 +0,0 @@
.custody-panel { display: grid; gap: 1.2rem; padding-block: 1rem; }
.custody-panel h2, .custody-panel h3, .custody-panel h4, .custody-panel p { margin: 0; }
.custody-panel p { line-height: 1.65; }
.custody-identity { overflow-wrap: anywhere; font-size: .85rem; }
.custody-balances { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
.custody-balances > div, .custody-forms > form, .custody-records > article {
padding: 1.25rem; border: 1px solid var(--ctp-surface1); border-radius: 1rem; background: var(--ctp-mantle);
}
.custody-balances > div { display: grid; gap: .6rem; }
.custody-balances strong { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.custody-balances span { font-size: .65rem; letter-spacing: .13em; opacity: .65; }
.custody-forms { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.custody-forms > form, .custody-records { display: grid; gap: .8rem; }
.custody-forms input, .custody-forms select { width: 100%; box-sizing: border-box; padding: .75rem; border: 1px solid var(--ctp-surface1); border-radius: .5rem; background: var(--ctp-base); color: inherit; }
.custody-records > article { display: grid; gap: .75rem; }
@media (max-width: 750px) { .custody-balances, .custody-forms { grid-template-columns: 1fr; } }
-3
View File
File diff suppressed because one or more lines are too long
+2 -3
View File
@@ -1,5 +1,5 @@
import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
import { IS_MAINNET, PROFILE } from '../shared/network.ts';
import { IS_MAINNET } from '../shared/network.ts';
export type Wallet = { publicKey: string; address: string };
type Keys = Awaited<ReturnType<typeof decryptWallet>>;
@@ -12,8 +12,7 @@ let session: { keys: Keys; handle: Wallet; revision: string; opened: number; tou
let epoch = 0;
const listeners = new Set<() => void>();
const isVault = (value: Stored): value is Vault => 'format' in value && value.format === 'wikideck-local-vault';
const DATABASE = IS_MAINNET ? 'wikideck-mainnet-wallet-v1'
: PROFILE === 'testnet' ? 'wikideck-testnet-wallet-v2' : 'wikideck-pilot-wallet-v1';
const DATABASE = IS_MAINNET ? 'wikideck-mainnet-wallet-v1' : 'wikideck-testnet-wallet-v2';
const LOCK = `${DATABASE}:identity`;
const encode = (bytes: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(bytes)));