From cb95fdf850917f4783e124b6014336ab1328758c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 18:32:02 -0400 Subject: [PATCH] feat(rexchange): add P2P crypto/fiat exchange module with escrow & reputation Community members post buy/sell intents for CRDT tokens (cUSDC, $MYCO, fUSDC) against 8 fiat currencies. Bipartite solver matches intents every 60s. Escrow via token-service burn/mint trio. Reputation scoring with badges. 14 API routes, canvas shape with physics orbs, and landing page. Co-Authored-By: Claude Opus 4.6 --- lib/canvas-tools.ts | 21 + lib/folk-exchange-node.ts | 372 ++++++++++++++ lib/index.ts | 3 + modules/rexchange/exchange-rates.ts | 122 +++++ modules/rexchange/exchange-reputation.ts | 124 +++++ modules/rexchange/exchange-routes.ts | 617 +++++++++++++++++++++++ modules/rexchange/exchange-settlement.ts | 390 ++++++++++++++ modules/rexchange/exchange-solver.ts | 221 ++++++++ modules/rexchange/landing.ts | 239 +++++++++ modules/rexchange/mod.ts | 86 ++++ modules/rexchange/schemas.ts | 205 ++++++++ server/index.ts | 2 + 12 files changed, 2402 insertions(+) create mode 100644 lib/folk-exchange-node.ts create mode 100644 modules/rexchange/exchange-rates.ts create mode 100644 modules/rexchange/exchange-reputation.ts create mode 100644 modules/rexchange/exchange-routes.ts create mode 100644 modules/rexchange/exchange-settlement.ts create mode 100644 modules/rexchange/exchange-solver.ts create mode 100644 modules/rexchange/landing.ts create mode 100644 modules/rexchange/mod.ts create mode 100644 modules/rexchange/schemas.ts diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index 29f0e87..b15282e 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -470,6 +470,27 @@ registry.push( }, ); +// ── rExchange P2P Exchange Tool ── +registry.push({ + declaration: { + name: "create_exchange_node", + description: "Create a P2P exchange order board on the canvas. Shows buy/sell intents as colored orbs with live matching status. Use when the user wants to visualize or interact with the community exchange.", + parameters: { + type: "object", + properties: { + spaceSlug: { type: "string", description: "The space slug to load exchange intents from" }, + }, + required: ["spaceSlug"], + }, + }, + tagName: "folk-exchange-node", + moduleId: "rexchange", + buildProps: (args) => ({ + spaceSlug: args.spaceSlug || "demo", + }), + actionLabel: (args) => `Created exchange board for ${args.spaceSlug || "demo"}`, +}); + // ── ASCII Art Tool ── registry.push({ declaration: { diff --git a/lib/folk-exchange-node.ts b/lib/folk-exchange-node.ts new file mode 100644 index 0000000..490b148 --- /dev/null +++ b/lib/folk-exchange-node.ts @@ -0,0 +1,372 @@ +/** + * folk-exchange-node — Canvas shape rendering a P2P exchange order board. + * + * Buy orbs (green, left) ↔ sell orbs (amber, right) with connecting arcs + * for matched trades. Status badges on orbs. Polls /api/exchange/intents every 30s. + */ + +import { FolkShape } from './folk-shape'; +import { css, html } from './tags'; +import { getModuleApiBase } from '../shared/url-helpers'; + +// ── Constants ── + +const BUY_COLOR = '#10b981'; +const SELL_COLOR = '#f59e0b'; +const MATCH_ARC_COLOR = '#60a5fa'; +const BG_COLOR = '#0f172a'; +const TEXT_COLOR = '#e2e8f0'; +const MUTED_COLOR = '#64748b'; + +const TOKEN_ICONS: Record = { + cusdc: '💵', myco: '🌱', fusdc: '🎮', +}; + +// ── Types ── + +interface OrderIntent { + id: string; + creatorName: string; + side: 'buy' | 'sell'; + tokenId: string; + fiatCurrency: string; + tokenAmountMin: number; + tokenAmountMax: number; + rateType: string; + rateFixed?: number; + rateMarketBps?: number; + paymentMethods: string[]; + isStandingOrder: boolean; + status: string; +} + +interface Orb { + intent: OrderIntent; + x: number; + y: number; + radius: number; + vx: number; + vy: number; + phase: number; + opacity: number; + hoverT: number; + color: string; +} + +// ── Shape ── + +export class FolkExchangeNode extends FolkShape { + static override tagName = 'folk-exchange-node'; + + private _canvas: HTMLCanvasElement | null = null; + private _ctx: CanvasRenderingContext2D | null = null; + private _orbs: Orb[] = []; + private _hovered: Orb | null = null; + private _animFrame = 0; + private _pollTimer = 0; + private _spaceSlug = 'demo'; + private _intents: OrderIntent[] = []; + + override connectedCallback() { + super.connectedCallback(); + this._spaceSlug = this.getAttribute('space') || this.getAttribute('spaceSlug') || 'demo'; + this._setup(); + this._fetchIntents(); + this._pollTimer = window.setInterval(() => this._fetchIntents(), 30000); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this._animFrame) cancelAnimationFrame(this._animFrame); + if (this._pollTimer) clearInterval(this._pollTimer); + } + + private _setup() { + const shadow = this.shadowRoot!; + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'width:100%;height:100%;position:relative;overflow:hidden;border-radius:12px;background:' + BG_COLOR; + + // Header + const header = document.createElement('div'); + header.style.cssText = 'position:absolute;top:0;left:0;right:0;padding:12px 16px;z-index:1;display:flex;justify-content:space-between;align-items:center'; + header.innerHTML = ` + 💱 rExchange + 0 intents + `; + wrapper.appendChild(header); + + // Legend + const legend = document.createElement('div'); + legend.style.cssText = 'position:absolute;bottom:8px;left:0;right:0;display:flex;justify-content:center;gap:16px;z-index:1'; + legend.innerHTML = ` + + Buy + + + Sell + + + Matched + + `; + wrapper.appendChild(legend); + + // Canvas + this._canvas = document.createElement('canvas'); + this._canvas.style.cssText = 'width:100%;height:100%'; + wrapper.appendChild(this._canvas); + + shadow.appendChild(wrapper); + + this._canvas.addEventListener('mousemove', (e) => this._onMouseMove(e)); + this._canvas.addEventListener('mouseleave', () => { this._hovered = null; }); + + this._resizeCanvas(); + new ResizeObserver(() => this._resizeCanvas()).observe(wrapper); + this._animate(); + } + + private _resizeCanvas() { + if (!this._canvas) return; + const rect = this._canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this._canvas.width = rect.width * dpr; + this._canvas.height = rect.height * dpr; + this._ctx = this._canvas.getContext('2d'); + if (this._ctx) this._ctx.scale(dpr, dpr); + } + + private async _fetchIntents() { + try { + const base = getModuleApiBase('rexchange'); + const res = await fetch(`${base}/api/exchange/intents`); + if (!res.ok) return; + const data = await res.json() as { intents: OrderIntent[] }; + this._intents = data.intents || []; + this._syncOrbs(); + + const counter = this.shadowRoot?.querySelector('#intent-count'); + if (counter) counter.textContent = `${this._intents.length} intents`; + } catch { /* silent */ } + } + + private _syncOrbs() { + const rect = this._canvas?.getBoundingClientRect(); + if (!rect) return; + const w = rect.width; + const h = rect.height; + const existingIds = new Set(this._orbs.map(o => o.intent.id)); + const newIds = new Set(this._intents.map(i => i.id)); + + // Remove stale orbs + this._orbs = this._orbs.filter(o => newIds.has(o.intent.id)); + + // Add new orbs + for (const intent of this._intents) { + if (existingIds.has(intent.id)) continue; + + const isBuy = intent.side === 'buy'; + const baseX = isBuy ? w * 0.25 : w * 0.75; + const amount = intent.tokenAmountMax / 1_000_000; + const radius = Math.max(12, Math.min(30, 12 + Math.sqrt(amount) * 2)); + + this._orbs.push({ + intent, + x: baseX + (Math.random() - 0.5) * w * 0.3, + y: 60 + Math.random() * (h - 120), + radius, + vx: (Math.random() - 0.5) * 0.3, + vy: (Math.random() - 0.5) * 0.3, + phase: Math.random() * Math.PI * 2, + opacity: 0, + hoverT: 0, + color: isBuy ? BUY_COLOR : SELL_COLOR, + }); + } + } + + private _onMouseMove(e: MouseEvent) { + const rect = this._canvas?.getBoundingClientRect(); + if (!rect) return; + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + this._hovered = null; + for (const orb of this._orbs) { + const dx = mx - orb.x, dy = my - orb.y; + if (dx * dx + dy * dy < orb.radius * orb.radius) { + this._hovered = orb; + break; + } + } + this._canvas!.style.cursor = this._hovered ? 'pointer' : 'default'; + } + + private _animate() { + this._animFrame = requestAnimationFrame(() => this._animate()); + this._update(); + this._draw(); + } + + private _update() { + const rect = this._canvas?.getBoundingClientRect(); + if (!rect) return; + const w = rect.width; + const h = rect.height; + + for (const orb of this._orbs) { + orb.phase += 0.006; + orb.vx += Math.sin(orb.phase) * 0.002; + orb.vy += Math.cos(orb.phase * 0.8 + 1) * 0.002; + orb.vx *= 0.995; + orb.vy *= 0.995; + orb.x += orb.vx; + orb.y += orb.vy; + + // Constrain to side (buy=left, sell=right) + const isBuy = orb.intent.side === 'buy'; + const minX = isBuy ? orb.radius + 8 : w * 0.5 + orb.radius; + const maxX = isBuy ? w * 0.5 - orb.radius : w - orb.radius - 8; + const minY = 40 + orb.radius; + const maxY = h - 40 - orb.radius; + + if (orb.x < minX) { orb.x = minX; orb.vx *= -0.5; } + if (orb.x > maxX) { orb.x = maxX; orb.vx *= -0.5; } + if (orb.y < minY) { orb.y = minY; orb.vy *= -0.5; } + if (orb.y > maxY) { orb.y = maxY; orb.vy *= -0.5; } + + // Hover + const isH = this._hovered === orb; + orb.hoverT += ((isH ? 1 : 0) - orb.hoverT) * 0.12; + if (orb.opacity < 1) orb.opacity = Math.min(1, orb.opacity + 0.03); + } + } + + private _draw() { + const ctx = this._ctx; + const rect = this._canvas?.getBoundingClientRect(); + if (!ctx || !rect) return; + const w = rect.width; + const h = rect.height; + + ctx.clearRect(0, 0, w, h); + + // Divider line + ctx.save(); + ctx.strokeStyle = '#1e293b'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(w / 2, 40); + ctx.lineTo(w / 2, h - 30); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + + // Side labels + ctx.save(); + ctx.font = '600 11px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = BUY_COLOR + '80'; + ctx.fillText('BUY', w * 0.25, 35); + ctx.fillStyle = SELL_COLOR + '80'; + ctx.fillText('SELL', w * 0.75, 35); + ctx.restore(); + + // Draw orbs + for (const orb of this._orbs) { + if (orb.opacity < 0.01) continue; + ctx.save(); + ctx.globalAlpha = orb.opacity; + + // Glow on hover + if (orb.hoverT > 0.05) { + ctx.shadowColor = orb.color; + ctx.shadowBlur = 20 * orb.hoverT; + } + + const r = orb.radius * (1 + orb.hoverT * 0.15); + + // Outer glow + ctx.beginPath(); + ctx.arc(orb.x, orb.y, r, 0, Math.PI * 2); + ctx.fillStyle = orb.color + '15'; + ctx.fill(); + + // Inner circle + ctx.beginPath(); + ctx.arc(orb.x, orb.y, r * 0.82, 0, Math.PI * 2); + const g = ctx.createRadialGradient( + orb.x - r * 0.15, orb.y - r * 0.15, 0, + orb.x, orb.y, r * 0.82, + ); + g.addColorStop(0, orb.color + 'dd'); + g.addColorStop(1, orb.color); + ctx.fillStyle = g; + ctx.fill(); + ctx.shadowBlur = 0; + ctx.strokeStyle = orb.color; + ctx.lineWidth = 1; + ctx.stroke(); + + // Token icon + ctx.font = `${Math.max(10, r * 0.5)}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#fff'; + const icon = TOKEN_ICONS[orb.intent.tokenId] || '$'; + ctx.fillText(icon, orb.x, orb.y); + + // Standing order badge + if (orb.intent.isStandingOrder) { + ctx.beginPath(); + ctx.arc(orb.x + r * 0.65, orb.y - r * 0.65, 4, 0, Math.PI * 2); + ctx.fillStyle = '#3b82f6'; + ctx.fill(); + } + + ctx.restore(); + } + + // Hover tooltip + if (this._hovered) { + const orb = this._hovered; + const i = orb.intent; + const amount = i.tokenAmountMax / 1_000_000; + const rateStr = i.rateType === 'fixed' ? `${i.rateFixed} ${i.fiatCurrency}` : `market+${i.rateMarketBps}bps`; + const lines = [ + i.creatorName, + `${i.side.toUpperCase()} ${amount.toFixed(2)} ${i.tokenId}`, + `Rate: ${rateStr}`, + `Pay: ${i.paymentMethods.join(', ')}`, + ]; + + const tooltipX = orb.x + orb.radius + 12; + const tooltipY = Math.max(50, Math.min(h - 80, orb.y - 30)); + + ctx.save(); + ctx.fillStyle = '#1e293bee'; + const tw = 160; + const th = lines.length * 16 + 12; + const tx = tooltipX + tw > w ? orb.x - orb.radius - tw - 12 : tooltipX; + ctx.beginPath(); + ctx.roundRect(tx, tooltipY, tw, th, 6); + ctx.fill(); + + ctx.font = '600 11px system-ui, sans-serif'; + ctx.fillStyle = TEXT_COLOR; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + for (let l = 0; l < lines.length; l++) { + ctx.fillStyle = l === 0 ? TEXT_COLOR : MUTED_COLOR; + ctx.font = l === 0 ? '600 11px system-ui, sans-serif' : '11px system-ui, sans-serif'; + ctx.fillText(lines[l], tx + 8, tooltipY + 6 + l * 16); + } + ctx.restore(); + } + } +} + +if (typeof customElements !== 'undefined' && !customElements.get('folk-exchange-node')) { + customElements.define('folk-exchange-node', FolkExchangeNode); +} diff --git a/lib/index.ts b/lib/index.ts index 8aa4d07..5d55798 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -73,6 +73,9 @@ export * from "./folk-token-ledger"; export * from "./folk-commitment-pool"; export * from "./folk-task-request"; +// rExchange Canvas Shape +export * from "./folk-exchange-node"; + // Transaction Builder export * from "./folk-transaction-builder"; diff --git a/modules/rexchange/exchange-rates.ts b/modules/rexchange/exchange-rates.ts new file mode 100644 index 0000000..9bf6ad2 --- /dev/null +++ b/modules/rexchange/exchange-rates.ts @@ -0,0 +1,122 @@ +/** + * Exchange rate feed — 5-min cached CoinGecko USD/fiat pairs. + * + * cUSDC is pegged to USDC (≈ $1 USD), so cUSDC/fiat ≈ USD/fiat. + * $MYCO uses bonding curve price × USD/fiat rate. + */ + +import type { TokenId, FiatCurrency } from './schemas'; + +// ── Cache ── + +interface RateEntry { + rates: Record; // fiat currency → USD/fiat rate + ts: number; +} + +const TTL = 5 * 60 * 1000; +let cached: RateEntry | null = null; +let inFlight: Promise | null = null; + +const CG_API_KEY = process.env.COINGECKO_API_KEY || ''; + +function cgUrl(url: string): string { + if (!CG_API_KEY) return url; + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}x_cg_demo_api_key=${CG_API_KEY}`; +} + +const FIAT_IDS: Record = { + EUR: 'eur', USD: 'usd', GBP: 'gbp', BRL: 'brl', + MXN: 'mxn', INR: 'inr', NGN: 'ngn', ARS: 'ars', +}; + +async function fetchRates(): Promise { + if (cached && Date.now() - cached.ts < TTL) return cached; + if (inFlight) return inFlight; + + inFlight = (async (): Promise => { + try { + const currencies = Object.values(FIAT_IDS).join(','); + const res = await fetch( + cgUrl(`https://api.coingecko.com/api/v3/simple/price?ids=usd-coin&vs_currencies=${currencies}`), + { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(10000) }, + ); + if (res.status === 429) { + console.warn('[exchange-rates] CoinGecko rate limited, waiting 60s...'); + await new Promise(r => setTimeout(r, 60000)); + const retry = await fetch( + cgUrl(`https://api.coingecko.com/api/v3/simple/price?ids=usd-coin&vs_currencies=${currencies}`), + { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(10000) }, + ); + if (!retry.ok) return cached || { rates: {}, ts: Date.now() }; + const data = await retry.json() as any; + const entry: RateEntry = { rates: data['usd-coin'] || {}, ts: Date.now() }; + cached = entry; + return entry; + } + if (!res.ok) return cached || { rates: {}, ts: Date.now() }; + const data = await res.json() as any; + const entry: RateEntry = { rates: data['usd-coin'] || {}, ts: Date.now() }; + cached = entry; + return entry; + } catch (e) { + console.warn('[exchange-rates] Failed to fetch rates:', e); + return cached || { rates: {}, ts: Date.now() }; + } finally { + inFlight = null; + } + })(); + + return inFlight; +} + +/** + * Get the exchange rate for a token in a fiat currency. + * Returns fiat amount per 1 whole token (not base units). + */ +export async function getExchangeRate(tokenId: TokenId, fiat: FiatCurrency): Promise { + const fiatKey = FIAT_IDS[fiat]; + if (!fiatKey) return 0; + + const entry = await fetchRates(); + const usdFiatRate = entry.rates[fiatKey] || 0; + + if (tokenId === 'cusdc' || tokenId === 'fusdc') { + // cUSDC/fUSDC ≈ 1 USD, so rate = USD/fiat + return usdFiatRate; + } + + if (tokenId === 'myco') { + // $MYCO price from bonding curve × USD/fiat + try { + const { calculatePrice } = await import('../../server/bonding-curve'); + const { getTokenDoc } = await import('../../server/token-service'); + const doc = getTokenDoc('myco'); + const supply = doc?.token?.totalSupply || 0; + const priceInCusdcBase = calculatePrice(supply); + // priceInCusdcBase is cUSDC base units per 1 MYCO base unit + // Convert to USD: base / 1_000_000 + const priceInUsd = priceInCusdcBase / 1_000_000; + return priceInUsd * usdFiatRate; + } catch { + return 0; + } + } + + return 0; +} + +/** + * Get all supported fiat rates for a token. Returns { currency → rate }. + */ +export async function getAllRates(tokenId: TokenId): Promise> { + const result: Record = {}; + const fiats: FiatCurrency[] = ['EUR', 'USD', 'GBP', 'BRL', 'MXN', 'INR', 'NGN', 'ARS']; + // Fetch once (cached), then compute per-fiat + await fetchRates(); + for (const fiat of fiats) { + result[fiat] = await getExchangeRate(tokenId, fiat); + } + return result; +} diff --git a/modules/rexchange/exchange-reputation.ts b/modules/rexchange/exchange-reputation.ts new file mode 100644 index 0000000..da12c40 --- /dev/null +++ b/modules/rexchange/exchange-reputation.ts @@ -0,0 +1,124 @@ +/** + * Exchange reputation scoring and badge calculation. + * + * Score formula (0-100): + * completionRate × 50 + (1 - disputeRate) × 25 + (1 - disputeLossRate) × 15 + confirmSpeed × 10 + * + * Badges: + * verified_seller — 5+ completed trades, score ≥ 70 + * liquidity_provider — has standing orders + * top_trader — ≥ $10k equivalent volume + */ + +import type { ExchangeReputationRecord, ExchangeReputationDoc, ExchangeTradesDoc } from './schemas'; + +export const DEFAULT_REPUTATION: ExchangeReputationRecord = { + did: '', + tradesCompleted: 0, + tradesCancelled: 0, + disputesRaised: 0, + disputesLost: 0, + totalVolumeBase: 0, + avgConfirmTimeMs: 0, + score: 50, + badges: [], +}; + +/** + * Calculate reputation score from raw stats. + */ +export function calculateScore(rec: ExchangeReputationRecord): number { + const totalTrades = rec.tradesCompleted + rec.tradesCancelled; + if (totalTrades === 0) return 50; // neutral default + + const completionRate = rec.tradesCompleted / totalTrades; + const totalDisputes = rec.disputesRaised; + const disputeRate = totalTrades > 0 ? totalDisputes / totalTrades : 0; + const disputeLossRate = totalDisputes > 0 ? rec.disputesLost / totalDisputes : 0; + + // Confirm speed: normalize to 0-1 (faster = higher). + // Target: < 1hr = perfect, > 24hr = 0. avgConfirmTimeMs capped at 24h. + const oneHour = 3600_000; + const twentyFourHours = 86400_000; + const speedScore = rec.avgConfirmTimeMs <= 0 ? 0.5 + : rec.avgConfirmTimeMs <= oneHour ? 1.0 + : Math.max(0, 1 - (rec.avgConfirmTimeMs - oneHour) / (twentyFourHours - oneHour)); + + const score = + completionRate * 50 + + (1 - disputeRate) * 25 + + (1 - disputeLossRate) * 15 + + speedScore * 10; + + return Math.round(Math.max(0, Math.min(100, score))); +} + +/** + * Compute badges based on reputation stats. + */ +export function computeBadges( + rec: ExchangeReputationRecord, + hasStandingOrders: boolean, +): string[] { + const badges: string[] = []; + + if (rec.tradesCompleted >= 5 && rec.score >= 70) { + badges.push('verified_seller'); + } + + if (hasStandingOrders) { + badges.push('liquidity_provider'); + } + + // $10k volume threshold — base units with 6 decimals → 10_000 * 1_000_000 + if (rec.totalVolumeBase >= 10_000_000_000) { + badges.push('top_trader'); + } + + return badges; +} + +/** + * Update reputation for a DID after a completed trade. + */ +export function updateReputationAfterTrade( + rec: ExchangeReputationRecord, + tokenAmount: number, + confirmTimeMs: number, +): ExchangeReputationRecord { + const newCompleted = rec.tradesCompleted + 1; + const newVolume = rec.totalVolumeBase + tokenAmount; + + // Running average of confirm time + const prevTotal = rec.avgConfirmTimeMs * rec.tradesCompleted; + const newAvg = newCompleted > 0 ? (prevTotal + confirmTimeMs) / newCompleted : 0; + + const updated: ExchangeReputationRecord = { + ...rec, + tradesCompleted: newCompleted, + totalVolumeBase: newVolume, + avgConfirmTimeMs: newAvg, + }; + + updated.score = calculateScore(updated); + return updated; +} + +/** + * Get reputation for a DID from the doc, or return defaults. + */ +export function getReputation(did: string, doc: ExchangeReputationDoc): ExchangeReputationRecord { + return doc.records[did] || { ...DEFAULT_REPUTATION, did }; +} + +/** + * Check if a DID has standing orders in the intents doc. + */ +export function hasStandingOrders( + did: string, + intentsDoc: { intents: Record }, +): boolean { + return Object.values(intentsDoc.intents).some( + i => i.creatorDid === did && i.isStandingOrder && i.status === 'active', + ); +} diff --git a/modules/rexchange/exchange-routes.ts b/modules/rexchange/exchange-routes.ts new file mode 100644 index 0000000..c157d2a --- /dev/null +++ b/modules/rexchange/exchange-routes.ts @@ -0,0 +1,617 @@ +/** + * rExchange API routes — P2P on/off-ramp exchange. + */ + +import { Hono } from 'hono'; +import * as Automerge from '@automerge/automerge'; +import { verifyToken, extractToken } from '../../server/auth'; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { + exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, + exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema, +} from './schemas'; +import type { + ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, + ExchangeIntent, ExchangeTrade, ExchangeSide, TokenId, FiatCurrency, RateType, +} from './schemas'; +import { solveExchange } from './exchange-solver'; +import { lockEscrow, releaseEscrow, reverseEscrow, resolveDispute, sweepTimeouts } from './exchange-settlement'; +import { getReputation } from './exchange-reputation'; +import { getExchangeRate, getAllRates } from './exchange-rates'; + +const VALID_TOKENS: TokenId[] = ['cusdc', 'myco', 'fusdc']; +const VALID_FIATS: FiatCurrency[] = ['EUR', 'USD', 'GBP', 'BRL', 'MXN', 'INR', 'NGN', 'ARS']; +const VALID_SIDES: ExchangeSide[] = ['buy', 'sell']; +const VALID_RATE_TYPES: RateType[] = ['fixed', 'market_plus_bps']; + +export function createExchangeRoutes(getSyncServer: () => SyncServer | null) { + const routes = new Hono(); + + // ── Helpers ── + + function ss(): SyncServer { + const s = getSyncServer(); + if (!s) throw new Error('SyncServer not initialized'); + return s; + } + + function ensureIntentsDoc(space: string): ExchangeIntentsDoc { + const syncServer = ss(); + 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 syncServer = ss(); + 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 syncServer = ss(); + 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; + } + + // ── POST /api/exchange/intent — Create buy/sell intent ── + + routes.post('/api/exchange/intent', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const body = await c.req.json(); + + const { side, tokenId, fiatCurrency, tokenAmountMin, tokenAmountMax, rateType } = body; + + // Validation + if (!VALID_SIDES.includes(side)) return c.json({ error: `side must be: ${VALID_SIDES.join(', ')}` }, 400); + if (!VALID_TOKENS.includes(tokenId)) return c.json({ error: `tokenId must be: ${VALID_TOKENS.join(', ')}` }, 400); + if (!VALID_FIATS.includes(fiatCurrency)) return c.json({ error: `fiatCurrency must be: ${VALID_FIATS.join(', ')}` }, 400); + if (!VALID_RATE_TYPES.includes(rateType)) return c.json({ error: `rateType must be: ${VALID_RATE_TYPES.join(', ')}` }, 400); + if (typeof tokenAmountMin !== 'number' || typeof tokenAmountMax !== 'number' || tokenAmountMin <= 0 || tokenAmountMax < tokenAmountMin) { + return c.json({ error: 'tokenAmountMin/Max must be positive numbers, max >= min' }, 400); + } + if (rateType === 'fixed' && (body.rateFixed == null || typeof body.rateFixed !== 'number')) { + return c.json({ error: 'rateFixed required for fixed rate type' }, 400); + } + if (rateType === 'market_plus_bps' && (body.rateMarketBps == null || typeof body.rateMarketBps !== 'number')) { + return c.json({ error: 'rateMarketBps required for market_plus_bps rate type' }, 400); + } + if (!body.paymentMethods?.length) { + return c.json({ error: 'At least one payment method required' }, 400); + } + + const id = crypto.randomUUID(); + const now = Date.now(); + ensureIntentsDoc(space); + + const intent: ExchangeIntent = { + id, + creatorDid: claims.did as string, + creatorName: claims.username as string || 'Unknown', + side, + tokenId, + fiatCurrency, + tokenAmountMin, + tokenAmountMax, + rateType, + rateFixed: body.rateFixed, + rateMarketBps: body.rateMarketBps, + paymentMethods: body.paymentMethods, + isStandingOrder: body.isStandingOrder || false, + autoAccept: body.autoAccept || false, + allowInstitutionalFallback: body.allowInstitutionalFallback || false, + minCounterpartyReputation: body.minCounterpartyReputation, + preferredCounterparties: body.preferredCounterparties, + status: 'active', + createdAt: now, + expiresAt: body.expiresAt, + }; + + ss().changeDoc(exchangeIntentsDocId(space), 'create exchange intent', (d) => { + d.intents[id] = intent as any; + }); + + const doc = ss().getDoc(exchangeIntentsDocId(space))!; + return c.json(doc.intents[id], 201); + }); + + // ── PATCH /api/exchange/intent/:id — Update/cancel intent ── + + routes.patch('/api/exchange/intent/:id', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + const body = await c.req.json(); + + ensureIntentsDoc(space); + const doc = ss().getDoc(exchangeIntentsDocId(space))!; + const intent = doc.intents[id]; + if (!intent) return c.json({ error: 'Intent not found' }, 404); + if (intent.creatorDid !== claims.did) return c.json({ error: 'Not your intent' }, 403); + + ss().changeDoc(exchangeIntentsDocId(space), 'update exchange intent', (d) => { + const i = d.intents[id]; + if (body.status === 'cancelled') i.status = 'cancelled' as any; + if (body.tokenAmountMin != null) i.tokenAmountMin = body.tokenAmountMin; + if (body.tokenAmountMax != null) i.tokenAmountMax = body.tokenAmountMax; + if (body.rateFixed != null) i.rateFixed = body.rateFixed as any; + if (body.rateMarketBps != null) i.rateMarketBps = body.rateMarketBps as any; + if (body.paymentMethods) i.paymentMethods = body.paymentMethods as any; + if (body.minCounterpartyReputation != null) i.minCounterpartyReputation = body.minCounterpartyReputation as any; + }); + + const updated = ss().getDoc(exchangeIntentsDocId(space))!; + return c.json(updated.intents[id]); + }); + + // ── GET /api/exchange/intents — Order book (active) ── + + routes.get('/api/exchange/intents', (c) => { + const space = c.req.param('space') || 'demo'; + ensureIntentsDoc(space); + const doc = ss().getDoc(exchangeIntentsDocId(space))!; + let intents = Object.values(doc.intents).filter(i => i.status === 'active'); + + // Optional filters + const tokenId = c.req.query('tokenId'); + const fiatCurrency = c.req.query('fiatCurrency'); + const side = c.req.query('side'); + if (tokenId) intents = intents.filter(i => i.tokenId === tokenId); + if (fiatCurrency) intents = intents.filter(i => i.fiatCurrency === fiatCurrency); + if (side) intents = intents.filter(i => i.side === side); + + return c.json({ intents }); + }); + + // ── GET /api/exchange/intents/mine — My intents ── + + routes.get('/api/exchange/intents/mine', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + ensureIntentsDoc(space); + const doc = ss().getDoc(exchangeIntentsDocId(space))!; + const intents = Object.values(doc.intents).filter(i => i.creatorDid === claims.did); + return c.json({ intents }); + }); + + // ── GET /api/exchange/matches — Pending matches for me ── + + routes.get('/api/exchange/matches', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + ensureTradesDoc(space); + const doc = ss().getDoc(exchangeTradesDocId(space))!; + + const matches = Object.values(doc.trades).filter(t => + t.status === 'proposed' && + (t.buyerDid === claims.did || t.sellerDid === claims.did), + ); + + return c.json({ matches }); + }); + + // ── POST /api/exchange/matches/:id/accept — Accept match ── + + routes.post('/api/exchange/matches/:id/accept', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.status !== 'proposed') return c.json({ error: `Trade status is ${trade.status}` }, 400); + if (trade.buyerDid !== claims.did && trade.sellerDid !== claims.did) { + return c.json({ error: 'Not a party to this trade' }, 403); + } + + ss().changeDoc(exchangeTradesDocId(space), 'accept match', (d) => { + d.trades[id].acceptances[claims.did as string] = true as any; + }); + + // Check if both accepted + const updated = ss().getDoc(exchangeTradesDocId(space))!; + const updatedTrade = updated.trades[id]; + const allAccepted = updatedTrade.acceptances[updatedTrade.buyerDid] && + updatedTrade.acceptances[updatedTrade.sellerDid]; + + if (allAccepted) { + // Lock escrow + ss().changeDoc(exchangeTradesDocId(space), 'mark accepted', (d) => { + d.trades[id].status = 'accepted' as any; + }); + + // Mark intents as matched + ss().changeDoc(exchangeIntentsDocId(space), 'mark intents matched', (d) => { + if (d.intents[updatedTrade.buyIntentId]) d.intents[updatedTrade.buyIntentId].status = 'matched' as any; + if (d.intents[updatedTrade.sellIntentId]) d.intents[updatedTrade.sellIntentId].status = 'matched' as any; + }); + + const freshDoc = ss().getDoc(exchangeTradesDocId(space))!; + const lockResult = lockEscrow(freshDoc.trades[id], ss(), space); + if (!lockResult.success) { + // Revert + ss().changeDoc(exchangeTradesDocId(space), 'revert failed escrow', (d) => { + d.trades[id].status = 'proposed' as any; + }); + return c.json({ error: `Escrow failed: ${lockResult.error}` }, 400); + } + } + + const final = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(final.trades[id]); + }); + + // ── POST /api/exchange/matches/:id/reject — Reject match ── + + routes.post('/api/exchange/matches/:id/reject', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.status !== 'proposed') return c.json({ error: `Trade status is ${trade.status}` }, 400); + + ss().changeDoc(exchangeTradesDocId(space), 'reject match', (d) => { + d.trades[id].status = 'cancelled' as any; + }); + + return c.json({ ok: true }); + }); + + // ── POST /api/exchange/trades/:id/fiat-sent — Buyer marks fiat sent ── + + routes.post('/api/exchange/trades/:id/fiat-sent', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.buyerDid !== claims.did) return c.json({ error: 'Only buyer can mark fiat sent' }, 403); + if (trade.status !== 'escrow_locked') return c.json({ error: `Expected escrow_locked, got ${trade.status}` }, 400); + + ss().changeDoc(exchangeTradesDocId(space), 'buyer marked fiat sent', (d) => { + d.trades[id].status = 'fiat_sent' as any; + }); + + const updated = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(updated.trades[id]); + }); + + // ── POST /api/exchange/trades/:id/confirm — Seller confirms fiat received ── + + routes.post('/api/exchange/trades/:id/confirm', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.sellerDid !== claims.did) return c.json({ error: 'Only seller can confirm fiat receipt' }, 403); + if (trade.status !== 'fiat_sent') return c.json({ error: `Expected fiat_sent, got ${trade.status}` }, 400); + + ss().changeDoc(exchangeTradesDocId(space), 'seller confirmed fiat', (d) => { + d.trades[id].status = 'fiat_confirmed' as any; + }); + + const freshDoc = ss().getDoc(exchangeTradesDocId(space))!; + const result = releaseEscrow(freshDoc.trades[id], ss(), space); + if (!result.success) { + return c.json({ error: `Release failed: ${result.error}` }, 500); + } + + const final = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(final.trades[id]); + }); + + // ── POST /api/exchange/trades/:id/dispute — Raise dispute ── + + routes.post('/api/exchange/trades/:id/dispute', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + const body = await c.req.json(); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.buyerDid !== claims.did && trade.sellerDid !== claims.did) { + return c.json({ error: 'Not a party to this trade' }, 403); + } + if (!['escrow_locked', 'fiat_sent', 'fiat_confirmed'].includes(trade.status)) { + return c.json({ error: `Cannot dispute in status ${trade.status}` }, 400); + } + + ss().changeDoc(exchangeTradesDocId(space), 'raise dispute', (d) => { + d.trades[id].status = 'disputed' as any; + if (body.reason) d.trades[id].disputeReason = body.reason as any; + }); + + // Track dispute in reputation + ensureReputationDoc(space); + ss().changeDoc(exchangeReputationDocId(space), 'track dispute', (d) => { + const disputerDid = claims.did as string; + if (!d.records[disputerDid]) { + d.records[disputerDid] = { + did: disputerDid, tradesCompleted: 0, tradesCancelled: 0, + disputesRaised: 0, disputesLost: 0, totalVolumeBase: 0, + avgConfirmTimeMs: 0, score: 50, badges: [], + } as any; + } + d.records[disputerDid].disputesRaised = (d.records[disputerDid].disputesRaised + 1) as any; + }); + + const updated = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(updated.trades[id]); + }); + + // ── POST /api/exchange/trades/:id/resolve — Admin resolve dispute ── + + routes.post('/api/exchange/trades/:id/resolve', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + // TODO: proper admin check — for MVP, any authenticated user can resolve + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + const body = await c.req.json(); + ensureTradesDoc(space); + ensureReputationDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.status !== 'disputed') return c.json({ error: `Expected disputed, got ${trade.status}` }, 400); + + const resolution = body.resolution as 'released_to_buyer' | 'returned_to_seller'; + if (resolution !== 'released_to_buyer' && resolution !== 'returned_to_seller') { + return c.json({ error: 'resolution must be released_to_buyer or returned_to_seller' }, 400); + } + + const ok = resolveDispute(trade, resolution, ss(), space); + if (!ok) return c.json({ error: 'Resolution failed' }, 500); + + const final = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(final.trades[id]); + }); + + // ── POST /api/exchange/trades/:id/message — Trade chat ── + + routes.post('/api/exchange/trades/:id/message', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + const body = await c.req.json(); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.buyerDid !== claims.did && trade.sellerDid !== claims.did) { + return c.json({ error: 'Not a party to this trade' }, 403); + } + if (!body.text?.trim()) return c.json({ error: 'text required' }, 400); + + const msgId = crypto.randomUUID(); + ss().changeDoc(exchangeTradesDocId(space), 'trade chat message', (d) => { + if (!d.trades[id].chatMessages) d.trades[id].chatMessages = [] as any; + (d.trades[id].chatMessages as any[]).push({ + id: msgId, + senderDid: claims.did, + senderName: claims.username || 'Unknown', + text: body.text.trim(), + timestamp: Date.now(), + }); + }); + + const updated = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(updated.trades[id]); + }); + + // ── GET /api/exchange/reputation/:did — Reputation lookup ── + + routes.get('/api/exchange/reputation/:did', (c) => { + const space = c.req.param('space') || 'demo'; + const did = c.req.param('did'); + ensureReputationDoc(space); + const doc = ss().getDoc(exchangeReputationDocId(space))!; + return c.json(getReputation(did, doc)); + }); + + // ── GET /api/exchange/rate/:token/:fiat — Live rate ── + + routes.get('/api/exchange/rate/:token/:fiat', async (c) => { + const tokenId = c.req.param('token') as TokenId; + const fiat = c.req.param('fiat') as FiatCurrency; + + if (!VALID_TOKENS.includes(tokenId)) return c.json({ error: 'Invalid token' }, 400); + if (!VALID_FIATS.includes(fiat)) return c.json({ error: 'Invalid fiat currency' }, 400); + + const rate = await getExchangeRate(tokenId, fiat); + return c.json({ tokenId, fiatCurrency: fiat, rate }); + }); + + return routes; +} + +// ── Solver Cron ── + +let _solverInterval: ReturnType | null = null; + +/** + * Start the periodic solver cron. Runs every 60s. + */ +export function startSolverCron(getSyncServer: () => SyncServer | null) { + if (_solverInterval) return; + + _solverInterval = setInterval(async () => { + const syncServer = getSyncServer(); + if (!syncServer) return; + + // Get all spaces that have exchange intents + const allDocIds = syncServer.listDocs(); + const spaces = allDocIds + .filter(id => id.endsWith(':rexchange:intents')) + .map(id => id.split(':')[0]); + + for (const space of spaces) { + try { + const intentsDoc = syncServer.getDoc(exchangeIntentsDocId(space)); + const reputationDoc = syncServer.getDoc(exchangeReputationDocId(space)); + if (!intentsDoc || !reputationDoc) continue; + + const activeIntents = Object.values(intentsDoc.intents).filter(i => i.status === 'active'); + const hasBuys = activeIntents.some(i => i.side === 'buy'); + const hasSells = activeIntents.some(i => i.side === 'sell'); + if (!hasBuys || !hasSells) continue; + + // Run solver + const matches = await solveExchange(intentsDoc, reputationDoc); + if (matches.length === 0) continue; + + // Create trade entries for proposed matches + const tradesDocId = exchangeTradesDocId(space); + let tradesDoc = syncServer.getDoc(tradesDocId); + if (!tradesDoc) { + tradesDoc = Automerge.change(Automerge.init(), 'init exchange trades', (d) => { + const init = exchangeTradesSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + syncServer.setDoc(tradesDocId, tradesDoc); + } + + // Only create trades for new matches (avoid duplicates) + const existingPairs = new Set( + Object.values(tradesDoc.trades) + .filter(t => t.status === 'proposed' || t.status === 'accepted' || t.status === 'escrow_locked' || t.status === 'fiat_sent') + .map(t => `${t.buyIntentId}:${t.sellIntentId}`), + ); + + for (const match of matches) { + const pairKey = `${match.buyIntentId}:${match.sellIntentId}`; + if (existingPairs.has(pairKey)) continue; + + const tradeId = crypto.randomUUID(); + syncServer.changeDoc(tradesDocId, 'solver: propose trade', (d) => { + d.trades[tradeId] = { + id: tradeId, + ...match, + chatMessages: [], + createdAt: Date.now(), + } as any; + }); + + // If both parties have autoAccept, immediately accept and lock escrow + const created = syncServer.getDoc(tradesDocId)!; + const trade = created.trades[tradeId]; + if (trade.acceptances[trade.buyerDid] && trade.acceptances[trade.sellerDid]) { + syncServer.changeDoc(tradesDocId, 'auto-accept trade', (d) => { + d.trades[tradeId].status = 'accepted' as any; + }); + syncServer.changeDoc(exchangeIntentsDocId(space), 'auto-match intents', (d) => { + if (d.intents[trade.buyIntentId]) d.intents[trade.buyIntentId].status = 'matched' as any; + if (d.intents[trade.sellIntentId]) d.intents[trade.sellIntentId].status = 'matched' as any; + }); + + const freshDoc = syncServer.getDoc(tradesDocId)!; + lockEscrow(freshDoc.trades[tradeId], syncServer, space); + } + + console.log(`[rExchange] Solver proposed trade ${tradeId}: ${match.buyerName} ↔ ${match.sellerName} for ${match.tokenAmount} ${match.tokenId}`); + } + + // Sweep timeouts + sweepTimeouts(syncServer, space); + } catch (e) { + console.warn(`[rExchange] Solver error for space ${space}:`, e); + } + } + }, 60_000); +} + +export function stopSolverCron() { + if (_solverInterval) { + clearInterval(_solverInterval); + _solverInterval = null; + } +} diff --git a/modules/rexchange/exchange-settlement.ts b/modules/rexchange/exchange-settlement.ts new file mode 100644 index 0000000..5f875dd --- /dev/null +++ b/modules/rexchange/exchange-settlement.ts @@ -0,0 +1,390 @@ +/** + * P2P Exchange Settlement — escrow lifecycle with saga rollback. + * + * Escrow mechanism (reuses token-service): + * 1. Lock: burnTokensEscrow(tokenId, sellerDid, amount, 'p2p-'+tradeId) — seller's tokens escrowed + * 2. Release: confirmBurn(tokenId, 'p2p-'+tradeId) + mintTokens(tokenId, buyerDid, amount) — net supply neutral + * 3. Reverse: reverseBurn(tokenId, 'p2p-'+tradeId) — seller gets tokens back + * + * Timeout sweep: trades with status=fiat_sent past fiatConfirmDeadline → auto reverseBurn. + * Disputes: admin calls resolve with resolution direction. + */ + +import type { SyncServer } from '../../server/local-first/sync-server'; +import { + burnTokensEscrow, confirmBurn, reverseBurn, + mintTokens, getTokenDoc, getBalance, +} from '../../server/token-service'; +import { + exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, +} from './schemas'; +import type { + ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, + ExchangeTrade, TradeStatus, +} from './schemas'; +import { updateReputationAfterTrade, calculateScore, computeBadges, hasStandingOrders } from './exchange-reputation'; + +// ── Constants ── + +const FIAT_CONFIRM_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours + +// ── Escrow Lock ── + +export interface LockResult { + success: boolean; + error?: string; +} + +/** + * Lock seller's tokens in escrow for a trade. + */ +export function lockEscrow( + trade: ExchangeTrade, + syncServer: SyncServer, + space: string, +): LockResult { + const doc = getTokenDoc(trade.tokenId); + if (!doc) return { success: false, error: `Token ${trade.tokenId} not found` }; + + // Check seller balance + const balance = getBalance(doc, trade.sellerDid); + if (balance < trade.tokenAmount) { + return { success: false, error: `Insufficient balance: ${balance} < ${trade.tokenAmount}` }; + } + + const offRampId = `p2p-${trade.id}`; + const success = burnTokensEscrow( + trade.tokenId, + trade.sellerDid, + trade.sellerName, + trade.tokenAmount, + offRampId, + `P2P escrow: ${trade.tokenAmount} ${trade.tokenId} for trade ${trade.id}`, + ); + + if (!success) { + return { success: false, error: 'Failed to create escrow entry' }; + } + + // Update trade with escrow reference + syncServer.changeDoc( + exchangeTradesDocId(space), + 'lock escrow', + (d) => { + if (d.trades[trade.id]) { + d.trades[trade.id].escrowTxId = offRampId; + d.trades[trade.id].status = 'escrow_locked' as any; + d.trades[trade.id].fiatConfirmDeadline = (Date.now() + FIAT_CONFIRM_TIMEOUT_MS) as any; + } + }, + ); + + return { success: true }; +} + +// ── Release (buyer confirmed fiat receipt by seller) ── + +export interface ReleaseResult { + success: boolean; + error?: string; +} + +/** + * Release escrowed tokens to buyer after seller confirms fiat receipt. + */ +export function releaseEscrow( + trade: ExchangeTrade, + syncServer: SyncServer, + space: string, +): ReleaseResult { + if (!trade.escrowTxId) { + return { success: false, error: 'No escrow reference on trade' }; + } + + // Confirm the burn (marks original burn as confirmed) + const burnOk = confirmBurn(trade.tokenId, trade.escrowTxId); + if (!burnOk) { + return { success: false, error: 'Failed to confirm escrow burn' }; + } + + // Mint equivalent tokens to buyer (net supply neutral) + const mintOk = mintTokens( + trade.tokenId, + trade.buyerDid, + trade.buyerName, + trade.tokenAmount, + `P2P exchange: received from ${trade.sellerName} (trade ${trade.id})`, + 'rexchange', + ); + + if (!mintOk) { + // Rollback: reverse the confirmed burn + reverseBurn(trade.tokenId, trade.escrowTxId); + return { success: false, error: 'Failed to mint tokens to buyer' }; + } + + const now = Date.now(); + + // Update trade status + syncServer.changeDoc( + exchangeTradesDocId(space), + 'release escrow — trade completed', + (d) => { + if (d.trades[trade.id]) { + d.trades[trade.id].status = 'completed' as any; + d.trades[trade.id].completedAt = now as any; + } + }, + ); + + // Update intent statuses + updateIntentsAfterTrade(trade, syncServer, space); + + // Update reputation for both parties + const confirmTime = trade.fiatConfirmDeadline + ? FIAT_CONFIRM_TIMEOUT_MS - (trade.fiatConfirmDeadline - now) + : 0; + updateReputationForTrade(trade, confirmTime, syncServer, space); + + return { success: true }; +} + +// ── Reverse (timeout or dispute resolution) ── + +/** + * Reverse escrow — return tokens to seller. + */ +export function reverseEscrow( + trade: ExchangeTrade, + reason: TradeStatus, + syncServer: SyncServer, + space: string, +): boolean { + if (!trade.escrowTxId) return false; + + const ok = reverseBurn(trade.tokenId, trade.escrowTxId); + if (!ok) return false; + + syncServer.changeDoc( + exchangeTradesDocId(space), + `reverse escrow — ${reason}`, + (d) => { + if (d.trades[trade.id]) { + d.trades[trade.id].status = reason as any; + } + }, + ); + + // Re-activate intents if not standing orders + reactivateIntents(trade, syncServer, space); + + return true; +} + +// ── Dispute resolution ── + +/** + * Resolve a disputed trade. Admin decides: release to buyer or return to seller. + */ +export function resolveDispute( + trade: ExchangeTrade, + resolution: 'released_to_buyer' | 'returned_to_seller', + syncServer: SyncServer, + space: string, +): boolean { + if (trade.status !== 'disputed') return false; + + syncServer.changeDoc( + exchangeTradesDocId(space), + `resolve dispute — ${resolution}`, + (d) => { + if (d.trades[trade.id]) { + d.trades[trade.id].resolution = resolution as any; + } + }, + ); + + if (resolution === 'released_to_buyer') { + const result = releaseEscrow(trade, syncServer, space); + if (!result.success) return false; + // Loser of dispute = seller + updateDisputeLoser(trade.sellerDid, syncServer, space); + } else { + const ok = reverseEscrow(trade, 'resolved', syncServer, space); + if (!ok) return false; + // Loser of dispute = buyer + updateDisputeLoser(trade.buyerDid, syncServer, space); + } + + return true; +} + +// ── Timeout sweep ── + +/** + * Check for timed-out trades and reverse their escrows. + * Called periodically by the solver cron. + */ +export function sweepTimeouts(syncServer: SyncServer, space: string): number { + const tradesDoc = syncServer.getDoc(exchangeTradesDocId(space)); + if (!tradesDoc) return 0; + + const now = Date.now(); + let reversed = 0; + + for (const trade of Object.values(tradesDoc.trades)) { + if ( + trade.status === 'fiat_sent' && + trade.fiatConfirmDeadline && + now > trade.fiatConfirmDeadline + ) { + const ok = reverseEscrow(trade, 'timed_out', syncServer, space); + if (ok) { + reversed++; + console.log(`[rExchange] Trade ${trade.id} timed out — escrow reversed`); + } + } + } + + return reversed; +} + +// ── Helpers ── + +function updateIntentsAfterTrade( + trade: ExchangeTrade, + syncServer: SyncServer, + space: string, +): void { + syncServer.changeDoc( + exchangeIntentsDocId(space), + 'update intents after trade completion', + (d) => { + const buyIntent = d.intents[trade.buyIntentId]; + const sellIntent = d.intents[trade.sellIntentId]; + + if (buyIntent) { + if (buyIntent.isStandingOrder) { + // Standing order: reduce range and re-activate + const newMin = Math.max(0, buyIntent.tokenAmountMin - trade.tokenAmount); + const newMax = Math.max(0, buyIntent.tokenAmountMax - trade.tokenAmount); + if (newMax > 0) { + buyIntent.tokenAmountMin = newMin as any; + buyIntent.tokenAmountMax = newMax as any; + buyIntent.status = 'active' as any; + } else { + buyIntent.status = 'completed' as any; + } + } else { + buyIntent.status = 'completed' as any; + } + } + + if (sellIntent) { + if (sellIntent.isStandingOrder) { + const newMin = Math.max(0, sellIntent.tokenAmountMin - trade.tokenAmount); + const newMax = Math.max(0, sellIntent.tokenAmountMax - trade.tokenAmount); + if (newMax > 0) { + sellIntent.tokenAmountMin = newMin as any; + sellIntent.tokenAmountMax = newMax as any; + sellIntent.status = 'active' as any; + } else { + sellIntent.status = 'completed' as any; + } + } else { + sellIntent.status = 'completed' as any; + } + } + }, + ); +} + +function reactivateIntents( + trade: ExchangeTrade, + syncServer: SyncServer, + space: string, +): void { + syncServer.changeDoc( + exchangeIntentsDocId(space), + 'reactivate intents after trade reversal', + (d) => { + const buyIntent = d.intents[trade.buyIntentId]; + const sellIntent = d.intents[trade.sellIntentId]; + if (buyIntent && buyIntent.status === 'matched') buyIntent.status = 'active' as any; + if (sellIntent && sellIntent.status === 'matched') sellIntent.status = 'active' as any; + }, + ); +} + +function updateReputationForTrade( + trade: ExchangeTrade, + confirmTimeMs: number, + syncServer: SyncServer, + space: string, +): void { + const repDocId = exchangeReputationDocId(space); + + syncServer.changeDoc(repDocId, 'update reputation after trade', (d) => { + for (const did of [trade.buyerDid, trade.sellerDid]) { + if (!d.records[did]) { + d.records[did] = { + did, + tradesCompleted: 0, + tradesCancelled: 0, + disputesRaised: 0, + disputesLost: 0, + totalVolumeBase: 0, + avgConfirmTimeMs: 0, + score: 50, + badges: [], + } as any; + } + + const rec = d.records[did]; + const newCompleted = rec.tradesCompleted + 1; + const prevTotal = rec.avgConfirmTimeMs * rec.tradesCompleted; + const newAvg = newCompleted > 0 ? (prevTotal + confirmTimeMs) / newCompleted : 0; + + rec.tradesCompleted = newCompleted as any; + rec.totalVolumeBase = (rec.totalVolumeBase + trade.tokenAmount) as any; + rec.avgConfirmTimeMs = newAvg as any; + + // Recalculate score inline (can't call external fn inside Automerge mutator with complex logic) + const totalTrades = rec.tradesCompleted + rec.tradesCancelled; + const completionRate = totalTrades > 0 ? rec.tradesCompleted / totalTrades : 0.5; + const disputeRate = totalTrades > 0 ? rec.disputesRaised / totalTrades : 0; + const disputeLossRate = rec.disputesRaised > 0 ? rec.disputesLost / rec.disputesRaised : 0; + const oneHour = 3600_000; + const twentyFourHours = 86400_000; + const speedScore = rec.avgConfirmTimeMs <= 0 ? 0.5 + : rec.avgConfirmTimeMs <= oneHour ? 1.0 + : Math.max(0, 1 - (rec.avgConfirmTimeMs - oneHour) / (twentyFourHours - oneHour)); + + rec.score = Math.round(Math.max(0, Math.min(100, + completionRate * 50 + (1 - disputeRate) * 25 + (1 - disputeLossRate) * 15 + speedScore * 10, + ))) as any; + + // Badges + const badges: string[] = []; + if (rec.tradesCompleted >= 5 && rec.score >= 70) badges.push('verified_seller'); + if (rec.totalVolumeBase >= 10_000_000_000) badges.push('top_trader'); + rec.badges = badges as any; + } + }); +} + +function updateDisputeLoser( + loserDid: string, + syncServer: SyncServer, + space: string, +): void { + syncServer.changeDoc( + exchangeReputationDocId(space), + 'update dispute loser', + (d) => { + if (d.records[loserDid]) { + d.records[loserDid].disputesLost = (d.records[loserDid].disputesLost + 1) as any; + } + }, + ); +} diff --git a/modules/rexchange/exchange-solver.ts b/modules/rexchange/exchange-solver.ts new file mode 100644 index 0000000..4285ad7 --- /dev/null +++ b/modules/rexchange/exchange-solver.ts @@ -0,0 +1,221 @@ +/** + * P2P Exchange Matching Engine — bipartite intent matching. + * + * Matches buy intents (want crypto, have fiat) with sell intents (have crypto, want fiat). + * + * Edge criteria (buy B ↔ sell S): + * 1. Same tokenId + fiatCurrency + * 2. Rate overlap (for market_plus_bps, evaluate against cached CoinGecko rate) + * 3. Amount overlap: min(B.max, S.max) >= max(B.min, S.min) + * 4. Reputation VPs satisfied both ways + * + * Scoring: 0.4×rateMutualness + 0.3×amountBalance + 0.2×avgReputation + 0.1×lpPriority + */ + +import type { + ExchangeIntent, ExchangeIntentsDoc, + ExchangeReputationDoc, ExchangeTrade, + TokenId, FiatCurrency, +} from './schemas'; +import { getReputation } from './exchange-reputation'; +import { getExchangeRate } from './exchange-rates'; + +// ── Config ── + +const TOP_K = 20; + +const W_RATE = 0.4; +const W_AMOUNT = 0.3; +const W_REPUTATION = 0.2; +const W_LP = 0.1; + +// ── Types ── + +interface Match { + buyIntent: ExchangeIntent; + sellIntent: ExchangeIntent; + agreedAmount: number; // token base units + agreedRate: number; // fiat per token + fiatAmount: number; + paymentMethod: string; + score: number; +} + +// ── Rate resolution ── + +/** Resolve the effective rate (fiat per token) for an intent, given the market rate. */ +function resolveRate(intent: ExchangeIntent, marketRate: number): number { + if (intent.rateType === 'fixed' && intent.rateFixed != null) { + return intent.rateFixed; + } + if (intent.rateType === 'market_plus_bps' && intent.rateMarketBps != null) { + const bps = intent.rateMarketBps; + const direction = intent.side === 'sell' ? 1 : -1; // seller adds spread, buyer subtracts + return marketRate * (1 + direction * bps / 10000); + } + return marketRate; +} + +// ── Solver ── + +/** + * Run the matching engine on active intents. + * Returns proposed matches sorted by score. + */ +export async function solveExchange( + intentsDoc: ExchangeIntentsDoc, + reputationDoc: ExchangeReputationDoc, +): Promise[]> { + const intents = Object.values(intentsDoc.intents).filter(i => i.status === 'active'); + const buys = intents.filter(i => i.side === 'buy'); + const sells = intents.filter(i => i.side === 'sell'); + + if (buys.length === 0 || sells.length === 0) return []; + + // Group by tokenId+fiatCurrency for efficiency + const pairGroups = new Map(); + for (const b of buys) { + const key = `${b.tokenId}:${b.fiatCurrency}`; + if (!pairGroups.has(key)) pairGroups.set(key, { buys: [], sells: [] }); + pairGroups.get(key)!.buys.push(b); + } + for (const s of sells) { + const key = `${s.tokenId}:${s.fiatCurrency}`; + if (!pairGroups.has(key)) pairGroups.set(key, { buys: [], sells: [] }); + pairGroups.get(key)!.sells.push(s); + } + + const allMatches: Match[] = []; + + for (const [pairKey, group] of pairGroups) { + if (group.buys.length === 0 || group.sells.length === 0) continue; + + const [tokenId, fiatCurrency] = pairKey.split(':') as [TokenId, FiatCurrency]; + const marketRate = await getExchangeRate(tokenId, fiatCurrency); + + for (const buy of group.buys) { + for (const sell of group.sells) { + // Don't match same user + if (buy.creatorDid === sell.creatorDid) continue; + + const match = evaluateMatch(buy, sell, marketRate, reputationDoc); + if (match) allMatches.push(match); + } + } + } + + // Sort by score descending, take top K + allMatches.sort((a, b) => b.score - a.score); + + // Deduplicate: each intent can appear in at most one match (greedy) + const usedIntents = new Set(); + const results: Omit[] = []; + + for (const match of allMatches) { + if (results.length >= TOP_K) break; + if (usedIntents.has(match.buyIntent.id) || usedIntents.has(match.sellIntent.id)) continue; + + usedIntents.add(match.buyIntent.id); + usedIntents.add(match.sellIntent.id); + + results.push({ + buyIntentId: match.buyIntent.id, + sellIntentId: match.sellIntent.id, + buyerDid: match.buyIntent.creatorDid, + buyerName: match.buyIntent.creatorName, + sellerDid: match.sellIntent.creatorDid, + sellerName: match.sellIntent.creatorName, + tokenId: match.buyIntent.tokenId, + tokenAmount: match.agreedAmount, + fiatCurrency: match.buyIntent.fiatCurrency, + fiatAmount: match.fiatAmount, + agreedRate: match.agreedRate, + paymentMethod: match.paymentMethod, + status: 'proposed', + acceptances: { + [match.buyIntent.creatorDid]: match.buyIntent.autoAccept, + [match.sellIntent.creatorDid]: match.sellIntent.autoAccept, + }, + }); + } + + return results; +} + +/** + * Evaluate a single buy/sell pair. Returns a Match if compatible, null otherwise. + */ +function evaluateMatch( + buy: ExchangeIntent, + sell: ExchangeIntent, + marketRate: number, + reputationDoc: ExchangeReputationDoc, +): Match | null { + // 1. Rate overlap + const buyRate = resolveRate(buy, marketRate); // max rate buyer will pay + const sellRate = resolveRate(sell, marketRate); // min rate seller will accept + + // Buyer's rate is the ceiling, seller's rate is the floor + // For a match, buyer must be willing to pay >= seller's ask + if (buyRate < sellRate) return null; + + const agreedRate = (buyRate + sellRate) / 2; // midpoint + + // 2. Amount overlap + const overlapMin = Math.max(buy.tokenAmountMin, sell.tokenAmountMin); + const overlapMax = Math.min(buy.tokenAmountMax, sell.tokenAmountMax); + if (overlapMax < overlapMin) return null; + + const agreedAmount = overlapMax; // fill as much as possible + + // 3. Reputation VPs + const buyerRep = getReputation(buy.creatorDid, reputationDoc); + const sellerRep = getReputation(sell.creatorDid, reputationDoc); + + if (sell.minCounterpartyReputation != null && buyerRep.score < sell.minCounterpartyReputation) return null; + if (buy.minCounterpartyReputation != null && sellerRep.score < buy.minCounterpartyReputation) return null; + + // Preferred counterparties (if set, counterparty must be in list) + if (buy.preferredCounterparties?.length && !buy.preferredCounterparties.includes(sell.creatorDid)) return null; + if (sell.preferredCounterparties?.length && !sell.preferredCounterparties.includes(buy.creatorDid)) return null; + + // 4. Payment method overlap + const commonMethods = buy.paymentMethods.filter(m => sell.paymentMethods.includes(m)); + if (commonMethods.length === 0) return null; + + // 5. Scoring + // Rate mutualness: how much slack between buyer's max and seller's min (0 = barely, 1 = generous) + const rateSlack = marketRate > 0 + ? Math.min(1, (buyRate - sellRate) / (marketRate * 0.05)) // normalize to 5% of market + : 0.5; + + // Amount balance: how well the agreed amount fills both sides + const buyFill = agreedAmount / buy.tokenAmountMax; + const sellFill = agreedAmount / sell.tokenAmountMax; + const amountBalance = (buyFill + sellFill) / 2; + + // Reputation average (0-1) + const avgRep = ((buyerRep.score + sellerRep.score) / 2) / 100; + + // LP priority: standing orders get a boost + const lpBoost = (buy.isStandingOrder || sell.isStandingOrder) ? 1.0 : 0.0; + + const score = Number(( + W_RATE * rateSlack + + W_AMOUNT * amountBalance + + W_REPUTATION * avgRep + + W_LP * lpBoost + ).toFixed(4)); + + const fiatAmount = (agreedAmount / 1_000_000) * agreedRate; + + return { + buyIntent: buy, + sellIntent: sell, + agreedAmount, + agreedRate, + fiatAmount, + paymentMethod: commonMethods[0], + score, + }; +} diff --git a/modules/rexchange/landing.ts b/modules/rexchange/landing.ts new file mode 100644 index 0000000..dec735c --- /dev/null +++ b/modules/rexchange/landing.ts @@ -0,0 +1,239 @@ +/** + * rExchange landing page — P2P crypto/fiat exchange within communities. + */ +export function renderLanding(): string { + return ` + +
+ + Part of the rSpace Ecosystem + +

+ Community
Exchange +

+

+ A peer-to-peer on/off-ramp where community members + act as exchange nodes for each other. Buy and sell cUSDC, $MYCO, and fUSDC against + local fiat currencies — with escrow, reputation, and intent matching. +

+ +
+ + +
+
+
+ ELI5 +

+ What is P2P Exchange? +

+

+ Think LocalBitcoins inside your rSpace community. + Members post buy/sell intents, the solver finds matches, and escrow handles trustless settlement. +

+
+ +
+ +
+
+
+ $ +
+

Post Intents

+
+

+ Buy or sell cUSDC, $MYCO, or fUSDC. Set your price, amount range, and accepted payment + methods (SEPA, Revolut, PIX, M-Pesa, Cash). + Standing orders let you be a liquidity provider. +

+
+ + +
+
+
+ +
+

Solver Matching

+
+

+ The matching engine runs every 60s, finding compatible buy/sell pairs by token, currency, + rate overlap, and reputation. Scored and ranked automatically. + Auto-accept for hands-free trading. +

+
+ + +
+
+
+ +
+

Escrow & Trust

+
+

+ Seller's tokens are locked in CRDT escrow. Buyer sends fiat off-chain, seller confirms + receipt, tokens release. 24h timeout auto-reverses. + Disputes resolved by space admins. +

+
+
+
+
+ + +
+
+
+ + How It Works + +

+ Intent → Match → Settle +

+
+ +
+
+
+
+ 1 +
+
+ Step 1 +

Post Intent

+
+
+

+ "I want to buy 100 cUSDC for EUR at market rate via SEPA" — or sell, at a fixed + price, with any payment method. Set reputation thresholds and amount ranges. +

+
+ +
+
+
+ 2 +
+
+ Step 2 +

Match & Escrow

+
+
+

+ Solver finds your counterparty. Both accept the match. Seller's tokens lock in escrow. + In-trade chat for coordinating fiat payment details. +

+
+ +
+
+
+ 3 +
+
+ Step 3 +

Settle & Build Rep

+
+
+

+ Buyer sends fiat, seller confirms receipt, tokens release instantly. Both parties earn + reputation score. Standing orders re-activate for continuous liquidity. +

+
+
+
+
+ + +
+
+
+

+ Built for Global Communities +

+
+ +
+
+
+ 🌍 +
+

8 Fiat Currencies

+

EUR, USD, GBP, BRL, MXN, INR, NGN, ARS with CoinGecko live rates.

+
+ +
+
+ 🔒 +
+

CRDT Escrow

+

Trustless token escrow with 24h timeout. Net supply neutral settlement.

+
+ +
+
+ +
+

Reputation

+

Score based on completion rate, dispute history, and confirmation speed. Badges earned.

+
+ +
+
+ 🤖 +
+

Auto-Matching

+

Intent solver runs every 60s. Auto-accept for hands-free LP trading.

+
+
+
+
+ + +
+
+
+ + Join the rSpace Ecosystem + +

+ Ready to exchange with your community? +

+

+ Create a Space and become a liquidity node. Post standing orders to provide exchange + access for your community — earn reputation and enable financial inclusion. +

+ +
+
+
+ +`; +} diff --git a/modules/rexchange/mod.ts b/modules/rexchange/mod.ts new file mode 100644 index 0000000..d35b3e2 --- /dev/null +++ b/modules/rexchange/mod.ts @@ -0,0 +1,86 @@ +/** + * 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 { 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, +} from './schemas'; +import { createExchangeRoutes, startSolverCron, stopSolverCron } 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); + +// ── 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: ``, + scripts: ``, + })); +}); + +// ── 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, + async onInit(ctx) { + _syncServer = ctx.syncServer; + 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' }, + ], +}; diff --git a/modules/rexchange/schemas.ts b/modules/rexchange/schemas.ts new file mode 100644 index 0000000..4a42b90 --- /dev/null +++ b/modules/rexchange/schemas.ts @@ -0,0 +1,205 @@ +/** + * rExchange Schemas — P2P on/off-ramp exchange intents, trades, and reputation. + * + * DocId formats: + * {space}:rexchange:intents → ExchangeIntentsDoc (active buy/sell intents) + * {space}:rexchange:trades → ExchangeTradesDoc (active & historical trades) + * {space}:rexchange:reputation → ExchangeReputationDoc (per-member trading reputation) + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Enums / Literals ── + +export type ExchangeSide = 'buy' | 'sell'; +export type TokenId = 'cusdc' | 'myco' | 'fusdc'; +export type FiatCurrency = 'EUR' | 'USD' | 'GBP' | 'BRL' | 'MXN' | 'INR' | 'NGN' | 'ARS'; +export type RateType = 'fixed' | 'market_plus_bps'; +export type IntentStatus = 'active' | 'matched' | 'completed' | 'cancelled' | 'expired'; + +export type TradeStatus = + | 'proposed' + | 'accepted' + | 'escrow_locked' + | 'fiat_sent' + | 'fiat_confirmed' + | 'completed' + | 'disputed' + | 'resolved' + | 'cancelled' + | 'timed_out'; + +// ── Exchange Intent ── + +export interface ExchangeIntent { + id: string; + creatorDid: string; + creatorName: string; + side: ExchangeSide; + tokenId: TokenId; + fiatCurrency: FiatCurrency; + tokenAmountMin: number; // base units (6 decimals) + tokenAmountMax: number; + rateType: RateType; + rateFixed?: number; // fiat per token (e.g. 0.98 EUR/cUSDC) + rateMarketBps?: number; // basis points spread over market rate + paymentMethods: string[]; // "SEPA", "Revolut", "PIX", "M-Pesa", "Cash", etc. + isStandingOrder: boolean; // LP flag — re-activates after fill + autoAccept: boolean; // skip manual match acceptance + allowInstitutionalFallback: boolean; // escalate to HyperSwitch if unmatched + minCounterpartyReputation?: number; // 0-100 + preferredCounterparties?: string[]; // DID list + status: IntentStatus; + createdAt: number; + expiresAt?: number; +} + +// ── Trade Chat Message ── + +export interface TradeChatMessage { + id: string; + senderDid: string; + senderName: string; + text: string; + timestamp: number; +} + +// ── Exchange Trade ── + +export interface ExchangeTrade { + id: string; + buyIntentId: string; + sellIntentId: string; + buyerDid: string; + buyerName: string; + sellerDid: string; + sellerName: string; + tokenId: TokenId; + tokenAmount: number; // agreed amount in base units + fiatCurrency: FiatCurrency; + fiatAmount: number; // agreed fiat amount + agreedRate: number; // fiat per token + paymentMethod: string; + escrowTxId?: string; + status: TradeStatus; + acceptances: Record; // did → accepted? + chatMessages: TradeChatMessage[]; + fiatConfirmDeadline?: number; // timestamp — 24h default + disputeReason?: string; + resolution?: 'released_to_buyer' | 'returned_to_seller'; + createdAt: number; + completedAt?: number; +} + +// ── Reputation ── + +export interface ExchangeReputationRecord { + did: string; + tradesCompleted: number; + tradesCancelled: number; + disputesRaised: number; + disputesLost: number; + totalVolumeBase: number; // total token volume in base units + avgConfirmTimeMs: number; + score: number; // 0-100 + badges: string[]; // 'verified_seller', 'liquidity_provider', 'top_trader' +} + +// ── Documents ── + +export interface ExchangeIntentsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + intents: Record; +} + +export interface ExchangeTradesDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + trades: Record; +} + +export interface ExchangeReputationDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + records: Record; +} + +// ── DocId helpers ── + +export function exchangeIntentsDocId(space: string) { + return `${space}:rexchange:intents` as const; +} + +export function exchangeTradesDocId(space: string) { + return `${space}:rexchange:trades` as const; +} + +export function exchangeReputationDocId(space: string) { + return `${space}:rexchange:reputation` as const; +} + +// ── Schema registrations ── + +export const exchangeIntentsSchema: DocSchema = { + module: 'rexchange', + collection: 'intents', + version: 1, + init: (): ExchangeIntentsDoc => ({ + meta: { + module: 'rexchange', + collection: 'intents', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + intents: {}, + }), +}; + +export const exchangeTradesSchema: DocSchema = { + module: 'rexchange', + collection: 'trades', + version: 1, + init: (): ExchangeTradesDoc => ({ + meta: { + module: 'rexchange', + collection: 'trades', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + trades: {}, + }), +}; + +export const exchangeReputationSchema: DocSchema = { + module: 'rexchange', + collection: 'reputation', + version: 1, + init: (): ExchangeReputationDoc => ({ + meta: { + module: 'rexchange', + collection: 'reputation', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + records: {}, + }), +}; diff --git a/server/index.ts b/server/index.ts index c5b53e3..5c9d7fa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -86,6 +86,7 @@ import { crowdsurfModule } from "../modules/crowdsurf/mod"; import { timeModule } from "../modules/rtime/mod"; import { govModule } from "../modules/rgov/mod"; import { sheetModule } from "../modules/rsheet/mod"; +import { exchangeModule } from "../modules/rexchange/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell"; @@ -143,6 +144,7 @@ registerModule(vnbModule); registerModule(crowdsurfModule); registerModule(timeModule); registerModule(govModule); // Governance decision circuits +registerModule(exchangeModule); // P2P crypto/fiat exchange registerModule(designModule); // Scribus DTP + AI design agent // De-emphasized modules (bottom of menu) registerModule(forumModule);