rspace-online/modules/rexchange/mod.ts

433 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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' },
],
};