Custodial markets

This commit is contained in:
2026-10-20 14:04:59 -04:00
parent 4e753b35fe
commit fc65e497a8
13 changed files with 1027 additions and 18 deletions
+154
View File
@@ -0,0 +1,154 @@
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) => { 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>}
{!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>;
}
+4 -2
View File
@@ -8,6 +8,7 @@ import { isXmrListing, isXmrOrder, marketMessageV3, type MarketActionV3, type Ma
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 };
@@ -48,7 +49,7 @@ function deadline(time: number, now: number) {
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'>('browse');
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');
@@ -141,7 +142,8 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props)
<header className="market-hero"><div><div className="section-eyebrow">THIS PEER’S COLLECTOR EXCHANGE</div><h1>Your next find.<br/><span>Your kind of market.</span></h1><p>Buy a fixed-price card or join a timed auction. Prices are listed directly in XMR atomic units (12 decimal places). Every peer can host its own market; listings and orders remain local to that host.</p></div><div className="market-symbol"><ShoppingBag size={52}/><span>PEER-HOSTED · COMMUNITY-DRIVEN</span></div></header>
<p className="muted">Sales support the hosting peer’s operator through a configurable fee and the project author through a hardcoded 1% fee. Both are deducted from seller proceeds. Collection-specific curation tools and peer-hosted custom TCGs 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'] as const).map(t=><button key={t} className={tab===t?'selected':''} onClick={()=>setTab(t)}>{t==='browse'?'Explore market':t==='sell'?'Sell a card':'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>
<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>}
+16
View File
@@ -0,0 +1,16 @@
.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; } }