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