diff --git a/modules/rexchange/mod.ts b/modules/rexchange/mod.ts index d35b3e2..f36f846 100644 --- a/modules/rexchange/mod.ts +++ b/modules/rexchange/mod.ts @@ -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(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('')} +
SideTokenAmountRatePaymentTraderFlags
`} +
+ + + ${activeTrades.length > 0 ? ` +
+

Active Trades

+ + + + + + + + + + ${activeTrades.map(tradeRow).join('')} +
AmountFiatBuyerSellerMethodStatus
+
` : ''} + + + ${completedTrades.length > 0 ? ` +
+

Recent Trades

+ + + + + + + + + + ${completedTrades.map(tradeRow).join('')} +
AmountFiatBuyerSellerMethodStatus
+
` : ''} +
+ +`; +} + // ── Page routes ── routes.get('/', (c) => { @@ -41,8 +386,7 @@ routes.get('/', (c) => { spaceSlug: space, modules: getModuleInfoList(), theme: 'dark', - body: ``, - scripts: ``, + 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: [