170 lines
16 KiB
TypeScript
Executable File
170 lines
16 KiB
TypeScript
Executable File
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>;
|
|
}
|