Files
wikideck/src/TradeDesk.tsx
T
2026-10-19 21:47:38 -04:00

225 lines
18 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}