/** * 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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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[] = [ // 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[] = [ { 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(exchangeIntentsDocId(space)); if (existing && Object.keys(existing.intents).length > 0) return; const now = Date.now(); // Seed intents ensureIntentsDoc(space); _syncServer.changeDoc(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(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(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(exchangeIntentsDocId(space)); const tradesDoc = _syncServer?.getDoc(exchangeTradesDocId(space)); const repDoc = _syncServer?.getDoc(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 = { 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 `${s.replace(/_/g, ' ')}`; } function repBadge(did: string) { const rec = repDoc?.records[did]; if (!rec) return 'new'; 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 `${rec.score}${badges}`; } function intentRow(i: typeof intents[0]) { const sideColor = i.side === 'buy' ? '#10b981' : '#f59e0b'; const icon = i.tokenId === 'cusdc' ? '💵' : i.tokenId === 'myco' ? '🌱' : '🎮'; return ` ${i.side} ${icon} ${i.tokenId} ${fmtAmount(i.tokenAmountMin)}–${fmtAmount(i.tokenAmountMax)} ${rateStr(i)} ${i.paymentMethods.join(', ')} ${i.creatorName} ${repBadge(i.creatorDid)} ${i.isStandingOrder ? 'LP' : ''} ${i.autoAccept ? 'auto' : ''} `; } function tradeRow(t: typeof trades[0]) { const icon = t.tokenId === 'cusdc' ? '💵' : t.tokenId === 'myco' ? '🌱' : '🎮'; return ` ${icon} ${fmtAmount(t.tokenAmount)} ${t.tokenId} ${t.fiatAmount.toFixed(2)} ${t.fiatCurrency} ${t.buyerName} ${repBadge(t.buyerDid)} ${t.sellerName} ${repBadge(t.sellerDid)} ${t.paymentMethod} ${statusBadge(t.status)} `; } return `

💱 rExchange

P2P order book for ${space}

${buyIntents.length} buys ${sellIntents.length} sells ${activeTrades.length} active ${completedTrades.length} settled

Order Book

${intents.filter(i => i.status === 'active').length === 0 ? '

No active intents

' : `${buyIntents.map(intentRow).join('')}${sellIntents.map(intentRow).join('')}
Side Token Amount Rate Payment Trader Flags
`}
${activeTrades.length > 0 ? `

Active Trades

${activeTrades.map(tradeRow).join('')}
Amount Fiat Buyer Seller Method Status
` : ''} ${completedTrades.length > 0 ? `

Recent Trades

${completedTrades.map(tradeRow).join('')}
Amount Fiat Buyer Seller Method Status
` : ''}
`; } // ── 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' }, ], };