fix(rexchange): add demo seeding and server-rendered order book page

Replace missing folk-exchange-app.js with server-rendered HTML order book.
Seed 8 demo intents, 2 trades, and 5 reputation records on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-04 18:57:59 -04:00
parent bf8e11d426
commit efb7ee5600
1 changed files with 349 additions and 3 deletions

View File

@ -12,6 +12,7 @@
*/
import { Hono } from 'hono';
import * as Automerge from '@automerge/automerge';
import { renderShell } from '../../server/shell';
import { getModuleInfoList } from '../../shared/module';
import type { RSpaceModule } from '../../shared/module';
@ -19,8 +20,13 @@ import type { SyncServer } from '../../server/local-first/sync-server';
import { renderLanding } from './landing';
import {
exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema,
exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId,
} from './schemas';
import { createExchangeRoutes, startSolverCron, stopSolverCron } from './exchange-routes';
import type {
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc,
ExchangeIntent, ExchangeTrade, ExchangeReputationRecord,
} from './schemas';
import { createExchangeRoutes, startSolverCron } from './exchange-routes';
const routes = new Hono();
@ -31,6 +37,345 @@ let _syncServer: SyncServer | null = null;
const exchangeRoutes = createExchangeRoutes(() => _syncServer);
routes.route('/', exchangeRoutes);
// ── Automerge helpers ──
function ensureIntentsDoc(space: string): ExchangeIntentsDoc {
const docId = exchangeIntentsDocId(space);
let doc = _syncServer!.getDoc<ExchangeIntentsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ExchangeIntentsDoc>(), 'init exchange intents', (d) => {
const init = exchangeIntentsSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
function ensureTradesDoc(space: string): ExchangeTradesDoc {
const docId = exchangeTradesDocId(space);
let doc = _syncServer!.getDoc<ExchangeTradesDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ExchangeTradesDoc>(), 'init exchange trades', (d) => {
const init = exchangeTradesSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
function ensureReputationDoc(space: string): ExchangeReputationDoc {
const docId = exchangeReputationDocId(space);
let doc = _syncServer!.getDoc<ExchangeReputationDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ExchangeReputationDoc>(), 'init exchange reputation', (d) => {
const init = exchangeReputationSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
// ── Demo seeding ──
const DEMO_DIDS = {
alice: 'did:key:alice-contributor-rspace-2026',
bob: 'did:key:bob-auditor-rspace-2026',
carol: 'did:key:carol-designer-rspace-2026',
maya: 'did:key:maya-facilitator-rspace-2026',
jordan: 'did:key:jordan-designer-rspace-2026',
};
const DEMO_INTENTS: Omit<ExchangeIntent, 'id' | 'createdAt'>[] = [
// Buys (want crypto, have fiat)
{
creatorDid: DEMO_DIDS.alice, creatorName: 'Alice',
side: 'buy', tokenId: 'cusdc', fiatCurrency: 'EUR',
tokenAmountMin: 50_000_000, tokenAmountMax: 200_000_000,
rateType: 'market_plus_bps', rateMarketBps: 50,
paymentMethods: ['SEPA', 'Revolut'], isStandingOrder: false,
autoAccept: false, allowInstitutionalFallback: true, status: 'active',
},
{
creatorDid: DEMO_DIDS.maya, creatorName: 'Maya',
side: 'buy', tokenId: 'cusdc', fiatCurrency: 'USD',
tokenAmountMin: 100_000_000, tokenAmountMax: 500_000_000,
rateType: 'fixed', rateFixed: 1.00,
paymentMethods: ['Revolut', 'Cash'], isStandingOrder: true,
autoAccept: true, allowInstitutionalFallback: false, status: 'active',
},
{
creatorDid: DEMO_DIDS.jordan, creatorName: 'Jordan',
side: 'buy', tokenId: 'myco', fiatCurrency: 'BRL',
tokenAmountMin: 1_000_000_000, tokenAmountMax: 5_000_000_000,
rateType: 'market_plus_bps', rateMarketBps: 100,
paymentMethods: ['PIX'], isStandingOrder: false,
autoAccept: false, allowInstitutionalFallback: true, status: 'active',
},
{
creatorDid: DEMO_DIDS.carol, creatorName: 'Carol',
side: 'buy', tokenId: 'cusdc', fiatCurrency: 'GBP',
tokenAmountMin: 25_000_000, tokenAmountMax: 100_000_000,
rateType: 'fixed', rateFixed: 0.79,
paymentMethods: ['SEPA', 'Revolut'], isStandingOrder: false,
autoAccept: false, allowInstitutionalFallback: false, status: 'active',
},
// Sells (have crypto, want fiat)
{
creatorDid: DEMO_DIDS.bob, creatorName: 'Bob',
side: 'sell', tokenId: 'cusdc', fiatCurrency: 'EUR',
tokenAmountMin: 100_000_000, tokenAmountMax: 300_000_000,
rateType: 'market_plus_bps', rateMarketBps: 30,
paymentMethods: ['SEPA'], isStandingOrder: true,
autoAccept: true, allowInstitutionalFallback: false, status: 'active',
},
{
creatorDid: DEMO_DIDS.carol, creatorName: 'Carol',
side: 'sell', tokenId: 'cusdc', fiatCurrency: 'USD',
tokenAmountMin: 50_000_000, tokenAmountMax: 250_000_000,
rateType: 'fixed', rateFixed: 1.01,
paymentMethods: ['Revolut', 'Cash'], isStandingOrder: false,
autoAccept: false, allowInstitutionalFallback: false, status: 'active',
},
{
creatorDid: DEMO_DIDS.alice, creatorName: 'Alice',
side: 'sell', tokenId: 'myco', fiatCurrency: 'EUR',
tokenAmountMin: 500_000_000, tokenAmountMax: 2_000_000_000,
rateType: 'market_plus_bps', rateMarketBps: 75,
paymentMethods: ['SEPA', 'Revolut'], isStandingOrder: false,
autoAccept: false, allowInstitutionalFallback: true, status: 'active',
},
{
creatorDid: DEMO_DIDS.maya, creatorName: 'Maya',
side: 'sell', tokenId: 'cusdc', fiatCurrency: 'GBP',
tokenAmountMin: 30_000_000, tokenAmountMax: 150_000_000,
rateType: 'fixed', rateFixed: 0.80,
paymentMethods: ['SEPA', 'Revolut'], isStandingOrder: true,
autoAccept: true, allowInstitutionalFallback: false, status: 'active',
},
];
const DEMO_REPUTATION: ExchangeReputationRecord[] = [
{ did: DEMO_DIDS.alice, tradesCompleted: 12, tradesCancelled: 1, disputesRaised: 0, disputesLost: 0, totalVolumeBase: 2_500_000_000, avgConfirmTimeMs: 1800_000, score: 88, badges: ['verified_seller'] },
{ did: DEMO_DIDS.bob, tradesCompleted: 27, tradesCancelled: 0, disputesRaised: 1, disputesLost: 0, totalVolumeBase: 8_000_000_000, avgConfirmTimeMs: 900_000, score: 95, badges: ['verified_seller'] },
{ did: DEMO_DIDS.carol, tradesCompleted: 6, tradesCancelled: 2, disputesRaised: 1, disputesLost: 1, totalVolumeBase: 1_200_000_000, avgConfirmTimeMs: 3600_000, score: 65, badges: [] },
{ did: DEMO_DIDS.maya, tradesCompleted: 45, tradesCancelled: 1, disputesRaised: 0, disputesLost: 0, totalVolumeBase: 15_000_000_000, avgConfirmTimeMs: 600_000, score: 97, badges: ['verified_seller', 'liquidity_provider', 'top_trader'] },
{ did: DEMO_DIDS.jordan, tradesCompleted: 3, tradesCancelled: 0, disputesRaised: 0, disputesLost: 0, totalVolumeBase: 500_000_000, avgConfirmTimeMs: 2400_000, score: 72, badges: [] },
];
const DEMO_TRADES: Omit<ExchangeTrade, 'id' | 'createdAt'>[] = [
{
buyIntentId: '', sellIntentId: '',
buyerDid: DEMO_DIDS.alice, buyerName: 'Alice',
sellerDid: DEMO_DIDS.bob, sellerName: 'Bob',
tokenId: 'cusdc', tokenAmount: 150_000_000, fiatCurrency: 'EUR',
fiatAmount: 138.75, agreedRate: 0.925, paymentMethod: 'SEPA',
status: 'completed', acceptances: { [DEMO_DIDS.alice]: true, [DEMO_DIDS.bob]: true },
chatMessages: [
{ id: 'msg-1', senderDid: DEMO_DIDS.alice, senderName: 'Alice', text: 'SEPA sent, ref: ALICE-BOB-001', timestamp: Date.now() - 86400_000 * 3 },
{ id: 'msg-2', senderDid: DEMO_DIDS.bob, senderName: 'Bob', text: 'Received, releasing escrow', timestamp: Date.now() - 86400_000 * 3 + 1800_000 },
],
completedAt: Date.now() - 86400_000 * 3,
},
{
buyIntentId: '', sellIntentId: '',
buyerDid: DEMO_DIDS.jordan, buyerName: 'Jordan',
sellerDid: DEMO_DIDS.carol, sellerName: 'Carol',
tokenId: 'cusdc', tokenAmount: 75_000_000, fiatCurrency: 'USD',
fiatAmount: 75.50, agreedRate: 1.007, paymentMethod: 'Revolut',
status: 'escrow_locked', acceptances: { [DEMO_DIDS.jordan]: true, [DEMO_DIDS.carol]: true },
chatMessages: [],
fiatConfirmDeadline: Date.now() + 86400_000,
},
];
function seedDemoIfEmpty(space: string = 'demo') {
if (!_syncServer) return;
const existing = _syncServer.getDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space));
if (existing && Object.keys(existing.intents).length > 0) return;
const now = Date.now();
// Seed intents
ensureIntentsDoc(space);
_syncServer.changeDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space), 'seed exchange intents', (d) => {
for (const intent of DEMO_INTENTS) {
const id = crypto.randomUUID();
d.intents[id] = { id, ...intent, createdAt: now } as any;
}
});
// Seed trades
ensureTradesDoc(space);
_syncServer.changeDoc<ExchangeTradesDoc>(exchangeTradesDocId(space), 'seed exchange trades', (d) => {
for (const trade of DEMO_TRADES) {
const id = crypto.randomUUID();
d.trades[id] = { id, ...trade, createdAt: now - 86400_000 * 5 } as any;
}
});
// Seed reputation
ensureReputationDoc(space);
_syncServer.changeDoc<ExchangeReputationDoc>(exchangeReputationDocId(space), 'seed exchange reputation', (d) => {
for (const rec of DEMO_REPUTATION) {
d.records[rec.did] = rec as any;
}
});
console.log(`[rExchange] Demo data seeded for "${space}": ${DEMO_INTENTS.length} intents, ${DEMO_TRADES.length} trades, ${DEMO_REPUTATION.length} reputation records`);
}
// ── Server-rendered order book page ──
function renderOrderBook(space: string): string {
const intentsDoc = _syncServer?.getDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space));
const tradesDoc = _syncServer?.getDoc<ExchangeTradesDoc>(exchangeTradesDocId(space));
const repDoc = _syncServer?.getDoc<ExchangeReputationDoc>(exchangeReputationDocId(space));
const intents = intentsDoc ? Object.values(intentsDoc.intents) : [];
const trades = tradesDoc ? Object.values(tradesDoc.trades) : [];
const buyIntents = intents.filter(i => i.status === 'active' && i.side === 'buy');
const sellIntents = intents.filter(i => i.status === 'active' && i.side === 'sell');
const activeTrades = trades.filter(t => !['completed', 'cancelled', 'timed_out', 'resolved'].includes(t.status));
const completedTrades = trades.filter(t => t.status === 'completed');
function fmtAmount(base: number) {
return (base / 1_000_000).toFixed(2);
}
function rateStr(i: { rateType: string; rateFixed?: number; rateMarketBps?: number; fiatCurrency: string }) {
if (i.rateType === 'fixed') return `${i.rateFixed} ${i.fiatCurrency}`;
return `mkt+${i.rateMarketBps}bps`;
}
function statusBadge(s: string) {
const colors: Record<string, string> = {
proposed: '#f59e0b', accepted: '#3b82f6', escrow_locked: '#8b5cf6',
fiat_sent: '#f97316', fiat_confirmed: '#10b981', completed: '#22c55e',
disputed: '#ef4444', resolved: '#6b7280', cancelled: '#6b7280', timed_out: '#6b7280',
};
const c = colors[s] || '#6b7280';
return `<span style="background:${c}22;color:${c};padding:2px 8px;border-radius:9999px;font-size:0.7rem;font-weight:600">${s.replace(/_/g, ' ')}</span>`;
}
function repBadge(did: string) {
const rec = repDoc?.records[did];
if (!rec) return '<span style="color:#64748b;font-size:0.7rem">new</span>';
const c = rec.score >= 80 ? '#22c55e' : rec.score >= 60 ? '#f59e0b' : '#ef4444';
const badges = rec.badges.length ? ` ${rec.badges.map(b => b === 'verified_seller' ? '✓' : b === 'liquidity_provider' ? '💧' : b === 'top_trader' ? '🏆' : '').join('')}` : '';
return `<span style="color:${c};font-size:0.7rem;font-weight:600">${rec.score}${badges}</span>`;
}
function intentRow(i: typeof intents[0]) {
const sideColor = i.side === 'buy' ? '#10b981' : '#f59e0b';
const icon = i.tokenId === 'cusdc' ? '💵' : i.tokenId === 'myco' ? '🌱' : '🎮';
return `<tr style="border-bottom:1px solid #1e293b">
<td style="padding:8px"><span style="color:${sideColor};font-weight:600;text-transform:uppercase">${i.side}</span></td>
<td style="padding:8px">${icon} ${i.tokenId}</td>
<td style="padding:8px">${fmtAmount(i.tokenAmountMin)}${fmtAmount(i.tokenAmountMax)}</td>
<td style="padding:8px">${rateStr(i)}</td>
<td style="padding:8px">${i.paymentMethods.join(', ')}</td>
<td style="padding:8px">${i.creatorName} ${repBadge(i.creatorDid)}</td>
<td style="padding:8px">${i.isStandingOrder ? '<span style="color:#3b82f6;font-size:0.7rem">LP</span>' : ''} ${i.autoAccept ? '<span style="color:#22c55e;font-size:0.7rem">auto</span>' : ''}</td>
</tr>`;
}
function tradeRow(t: typeof trades[0]) {
const icon = t.tokenId === 'cusdc' ? '💵' : t.tokenId === 'myco' ? '🌱' : '🎮';
return `<tr style="border-bottom:1px solid #1e293b">
<td style="padding:8px">${icon} ${fmtAmount(t.tokenAmount)} ${t.tokenId}</td>
<td style="padding:8px">${t.fiatAmount.toFixed(2)} ${t.fiatCurrency}</td>
<td style="padding:8px">${t.buyerName} ${repBadge(t.buyerDid)}</td>
<td style="padding:8px">${t.sellerName} ${repBadge(t.sellerDid)}</td>
<td style="padding:8px">${t.paymentMethod}</td>
<td style="padding:8px">${statusBadge(t.status)}</td>
</tr>`;
}
return `
<div style="max-width:960px;margin:0 auto;padding:24px 16px;color:#e2e8f0;font-family:system-ui,sans-serif">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
<div>
<h1 style="font-size:1.5rem;font-weight:700;margin:0;background:linear-gradient(to right,#f59e0b,#10b981);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
💱 rExchange
</h1>
<p style="color:#64748b;font-size:0.85rem;margin:4px 0 0">P2P order book for ${space}</p>
</div>
<div style="display:flex;gap:12px;font-size:0.8rem">
<span style="color:#10b981">${buyIntents.length} buys</span>
<span style="color:#f59e0b">${sellIntents.length} sells</span>
<span style="color:#3b82f6">${activeTrades.length} active</span>
<span style="color:#22c55e">${completedTrades.length} settled</span>
</div>
</div>
<!-- Active Order Book -->
<div style="background:#0f172a;border:1px solid #1e293b;border-radius:12px;padding:16px;margin-bottom:24px">
<h2 style="font-size:1rem;font-weight:600;margin:0 0 12px;color:#94a3b8">Order Book</h2>
${intents.filter(i => i.status === 'active').length === 0
? '<p style="color:#475569;text-align:center;padding:24px">No active intents</p>'
: `<table style="width:100%;border-collapse:collapse;font-size:0.85rem">
<thead><tr style="border-bottom:2px solid #1e293b;color:#64748b;font-size:0.75rem;text-transform:uppercase">
<th style="padding:8px;text-align:left">Side</th>
<th style="padding:8px;text-align:left">Token</th>
<th style="padding:8px;text-align:left">Amount</th>
<th style="padding:8px;text-align:left">Rate</th>
<th style="padding:8px;text-align:left">Payment</th>
<th style="padding:8px;text-align:left">Trader</th>
<th style="padding:8px;text-align:left">Flags</th>
</tr></thead>
<tbody>${buyIntents.map(intentRow).join('')}${sellIntents.map(intentRow).join('')}</tbody>
</table>`}
</div>
<!-- Active Trades -->
${activeTrades.length > 0 ? `
<div style="background:#0f172a;border:1px solid #1e293b;border-radius:12px;padding:16px;margin-bottom:24px">
<h2 style="font-size:1rem;font-weight:600;margin:0 0 12px;color:#94a3b8">Active Trades</h2>
<table style="width:100%;border-collapse:collapse;font-size:0.85rem">
<thead><tr style="border-bottom:2px solid #1e293b;color:#64748b;font-size:0.75rem;text-transform:uppercase">
<th style="padding:8px;text-align:left">Amount</th>
<th style="padding:8px;text-align:left">Fiat</th>
<th style="padding:8px;text-align:left">Buyer</th>
<th style="padding:8px;text-align:left">Seller</th>
<th style="padding:8px;text-align:left">Method</th>
<th style="padding:8px;text-align:left">Status</th>
</tr></thead>
<tbody>${activeTrades.map(tradeRow).join('')}</tbody>
</table>
</div>` : ''}
<!-- Recent Completed -->
${completedTrades.length > 0 ? `
<div style="background:#0f172a;border:1px solid #1e293b;border-radius:12px;padding:16px">
<h2 style="font-size:1rem;font-weight:600;margin:0 0 12px;color:#94a3b8">Recent Trades</h2>
<table style="width:100%;border-collapse:collapse;font-size:0.85rem">
<thead><tr style="border-bottom:2px solid #1e293b;color:#64748b;font-size:0.75rem;text-transform:uppercase">
<th style="padding:8px;text-align:left">Amount</th>
<th style="padding:8px;text-align:left">Fiat</th>
<th style="padding:8px;text-align:left">Buyer</th>
<th style="padding:8px;text-align:left">Seller</th>
<th style="padding:8px;text-align:left">Method</th>
<th style="padding:8px;text-align:left">Status</th>
</tr></thead>
<tbody>${completedTrades.map(tradeRow).join('')}</tbody>
</table>
</div>` : ''}
</div>
<script>
// Auto-refresh every 30s
setTimeout(() => location.reload(), 30000);
</script>`;
}
// ── Page routes ──
routes.get('/', (c) => {
@ -41,8 +386,7 @@ routes.get('/', (c) => {
spaceSlug: space,
modules: getModuleInfoList(),
theme: 'dark',
body: `<folk-exchange-app space="${space}"></folk-exchange-app>`,
scripts: `<script type="module" src="/modules/rexchange/folk-exchange-app.js?v=1"></script>`,
body: renderOrderBook(space),
}));
});
@ -63,8 +407,10 @@ export const exchangeModule: RSpaceModule = {
],
routes,
landingPage: renderLanding,
seedTemplate: seedDemoIfEmpty,
async onInit(ctx) {
_syncServer = ctx.syncServer;
seedDemoIfEmpty();
startSolverCron(() => _syncServer);
},
feeds: [