433 lines
19 KiB
TypeScript
433 lines
19 KiB
TypeScript
/**
|
||
* rExchange module — P2P crypto/fiat exchange within communities.
|
||
*
|
||
* Community members post buy/sell intents for CRDT tokens (cUSDC, $MYCO, fUSDC)
|
||
* against fiat currencies. Solver matches intents, escrow handles settlement.
|
||
*
|
||
* All state stored in Automerge documents via SyncServer.
|
||
* Doc layout:
|
||
* {space}:rexchange:intents → ExchangeIntentsDoc
|
||
* {space}:rexchange:trades → ExchangeTradesDoc
|
||
* {space}:rexchange:reputation → ExchangeReputationDoc
|
||
*/
|
||
|
||
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';
|
||
import type { SyncServer } from '../../server/local-first/sync-server';
|
||
import { renderLanding } from './landing';
|
||
import {
|
||
exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema,
|
||
exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId,
|
||
} from './schemas';
|
||
import type {
|
||
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc,
|
||
ExchangeIntent, ExchangeTrade, ExchangeReputationRecord,
|
||
} from './schemas';
|
||
import { createExchangeRoutes, startSolverCron } from './exchange-routes';
|
||
|
||
const routes = new Hono();
|
||
|
||
// ── SyncServer ref (set during onInit) ──
|
||
let _syncServer: SyncServer | null = null;
|
||
|
||
// ── Mount exchange routes ──
|
||
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) => {
|
||
const space = c.req.param('space') || 'demo';
|
||
return c.html(renderShell({
|
||
title: `${space} — rExchange | rSpace`,
|
||
moduleId: 'rexchange',
|
||
spaceSlug: space,
|
||
modules: getModuleInfoList(),
|
||
theme: 'dark',
|
||
body: renderOrderBook(space),
|
||
}));
|
||
});
|
||
|
||
// ── Module export ──
|
||
|
||
export const exchangeModule: RSpaceModule = {
|
||
id: 'rexchange',
|
||
name: 'rExchange',
|
||
icon: '💱',
|
||
description: 'P2P crypto/fiat exchange with escrow & reputation',
|
||
canvasShapes: ['folk-exchange-node'],
|
||
canvasToolIds: ['create_exchange_node'],
|
||
scoping: { defaultScope: 'space', userConfigurable: false },
|
||
docSchemas: [
|
||
{ pattern: '{space}:rexchange:intents', description: 'Buy/sell intent order book', init: exchangeIntentsSchema.init },
|
||
{ pattern: '{space}:rexchange:trades', description: 'Active and historical trades', init: exchangeTradesSchema.init },
|
||
{ pattern: '{space}:rexchange:reputation', description: 'Per-member exchange reputation', init: exchangeReputationSchema.init },
|
||
],
|
||
routes,
|
||
landingPage: renderLanding,
|
||
seedTemplate: seedDemoIfEmpty,
|
||
async onInit(ctx) {
|
||
_syncServer = ctx.syncServer;
|
||
seedDemoIfEmpty();
|
||
startSolverCron(() => _syncServer);
|
||
},
|
||
feeds: [
|
||
{
|
||
id: 'exchange-trades',
|
||
name: 'Exchange Trades',
|
||
kind: 'economic',
|
||
description: 'P2P exchange trade completions',
|
||
filterable: true,
|
||
},
|
||
],
|
||
outputPaths: [
|
||
{ path: 'canvas', name: 'Order Book', icon: '📊', description: 'Visual order book with buy/sell orbs' },
|
||
{ path: 'collaborate', name: 'My Trades', icon: '🤝', description: 'Active trades and chat' },
|
||
],
|
||
onboardingActions: [
|
||
{ label: 'Post Intent', icon: '💱', description: 'Post a buy or sell intent', type: 'create', href: '/rexchange' },
|
||
],
|
||
};
|