rspace-online/modules/rexchange/exchange-routes.ts

799 lines
32 KiB
TypeScript

/**
* 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, exchangePoolsDocId,
exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema, exchangePoolsSchema,
} from './schemas';
import type {
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, ExchangePoolsDoc,
ExchangeIntent, ExchangeTrade, ExchangeSide, TokenId, FiatCurrency, RateType,
LiquidityPosition,
} 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<ExchangeIntentsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ExchangeIntentsDoc>(), 'init exchange intents', (d) => {
const init = exchangeIntentsSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer.setDoc(docId, doc);
}
return doc;
}
function ensureTradesDoc(space: string): ExchangeTradesDoc {
const syncServer = ss();
const docId = exchangeTradesDocId(space);
let doc = syncServer.getDoc<ExchangeTradesDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ExchangeTradesDoc>(), 'init exchange trades', (d) => {
const init = exchangeTradesSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer.setDoc(docId, doc);
}
return doc;
}
function ensureReputationDoc(space: string): ExchangeReputationDoc {
const syncServer = ss();
const docId = exchangeReputationDocId(space);
let doc = syncServer.getDoc<ExchangeReputationDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ExchangeReputationDoc>(), 'init exchange reputation', (d) => {
const init = exchangeReputationSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer.setDoc(docId, doc);
}
return doc;
}
function ensurePoolsDoc(space: string): ExchangePoolsDoc {
const syncServer = ss();
const docId = exchangePoolsDocId(space);
let doc = syncServer.getDoc<ExchangePoolsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ExchangePoolsDoc>(), 'init exchange pools', (d) => {
const init = exchangePoolsSchema.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<ExchangeIntentsDoc>(exchangeIntentsDocId(space), 'create exchange intent', (d) => {
d.intents[id] = intent as any;
});
const doc = ss().getDoc<ExchangeIntentsDoc>(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<ExchangeIntentsDoc>(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<ExchangeIntentsDoc>(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<ExchangeIntentsDoc>(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<ExchangeIntentsDoc>(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<ExchangeIntentsDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(exchangeTradesDocId(space), 'accept match', (d) => {
d.trades[id].acceptances[claims.did as string] = true as any;
});
// Check if both accepted
const updated = ss().getDoc<ExchangeTradesDoc>(exchangeTradesDocId(space))!;
const updatedTrade = updated.trades[id];
const allAccepted = updatedTrade.acceptances[updatedTrade.buyerDid] &&
updatedTrade.acceptances[updatedTrade.sellerDid];
if (allAccepted) {
// Lock escrow
ss().changeDoc<ExchangeTradesDoc>(exchangeTradesDocId(space), 'mark accepted', (d) => {
d.trades[id].status = 'accepted' as any;
});
// Mark intents as matched
ss().changeDoc<ExchangeIntentsDoc>(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<ExchangeTradesDoc>(exchangeTradesDocId(space))!;
const lockResult = lockEscrow(freshDoc.trades[id], ss(), space);
if (!lockResult.success) {
// Revert
ss().changeDoc<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(exchangeTradesDocId(space), 'buyer marked fiat sent', (d) => {
d.trades[id].status = 'fiat_sent' as any;
});
const updated = ss().getDoc<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(exchangeTradesDocId(space), 'seller confirmed fiat', (d) => {
d.trades[id].status = 'fiat_confirmed' as any;
});
const freshDoc = ss().getDoc<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeReputationDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(exchangeTradesDocId(space))!;
return c.json(updated.trades[id]);
});
// ── POST /api/exchange/pool — Add Liquidity ──
routes.post('/api/exchange/pool', 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 { tokenId, fiatCurrency, tokenAmount, fiatAmount, spreadBps, paymentMethods } = body;
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 (typeof tokenAmount !== 'number' || tokenAmount <= 0) return c.json({ error: 'tokenAmount must be positive' }, 400);
if (typeof fiatAmount !== 'number' || fiatAmount <= 0) return c.json({ error: 'fiatAmount must be positive' }, 400);
if (typeof spreadBps !== 'number' || spreadBps < 0 || spreadBps > 1000) return c.json({ error: 'spreadBps must be 0-1000' }, 400);
if (!paymentMethods?.length) return c.json({ error: 'At least one payment method required' }, 400);
const positionId = crypto.randomUUID();
const buyIntentId = crypto.randomUUID();
const sellIntentId = crypto.randomUUID();
const now = Date.now();
const creatorDid = claims.did as string;
const creatorName = claims.username as string || 'Unknown';
// Create paired standing orders — buy at market-spreadBps, sell at market+spreadBps
ensureIntentsDoc(space);
const buyIntent: ExchangeIntent = {
id: buyIntentId, creatorDid, creatorName,
side: 'buy', tokenId, fiatCurrency,
tokenAmountMin: 1_000_000, // 1 token minimum per fill
tokenAmountMax: tokenAmount,
rateType: 'market_plus_bps', rateMarketBps: spreadBps,
paymentMethods, isStandingOrder: true, autoAccept: true,
allowInstitutionalFallback: false, status: 'active',
createdAt: now,
};
const sellIntent: ExchangeIntent = {
id: sellIntentId, creatorDid, creatorName,
side: 'sell', tokenId, fiatCurrency,
tokenAmountMin: 1_000_000,
tokenAmountMax: tokenAmount,
rateType: 'market_plus_bps', rateMarketBps: spreadBps,
paymentMethods, isStandingOrder: true, autoAccept: true,
allowInstitutionalFallback: false, status: 'active',
createdAt: now,
};
ss().changeDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space), 'create LP paired intents', (d) => {
d.intents[buyIntentId] = buyIntent as any;
d.intents[sellIntentId] = sellIntent as any;
});
// Create the liquidity position
const position: LiquidityPosition = {
id: positionId, creatorDid, creatorName,
tokenId, fiatCurrency,
tokenCommitted: tokenAmount, tokenRemaining: tokenAmount,
fiatCommitted: fiatAmount, fiatRemaining: fiatAmount,
spreadBps, paymentMethods,
buyIntentId, sellIntentId,
feesEarnedToken: 0, feesEarnedFiat: 0, tradesMatched: 0,
status: 'active', createdAt: now, updatedAt: now,
};
ensurePoolsDoc(space);
ss().changeDoc<ExchangePoolsDoc>(exchangePoolsDocId(space), 'create liquidity position', (d) => {
d.positions[positionId] = position as any;
});
const doc = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
return c.json(doc.positions[positionId], 201);
});
// ── GET /api/exchange/pools — List all active positions ──
routes.get('/api/exchange/pools', (c) => {
const space = c.req.param('space') || 'demo';
ensurePoolsDoc(space);
const doc = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
const positions = Object.values(doc.positions).filter(p => p.status === 'active');
return c.json({ positions });
});
// ── GET /api/exchange/pools/mine — My positions ──
routes.get('/api/exchange/pools/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';
ensurePoolsDoc(space);
const doc = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
const positions = Object.values(doc.positions).filter(p => p.creatorDid === claims.did);
return c.json({ positions });
});
// ── PATCH /api/exchange/pool/:id — Update or withdraw ──
routes.patch('/api/exchange/pool/: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();
ensurePoolsDoc(space);
const doc = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
const position = doc.positions[id];
if (!position) return c.json({ error: 'Position not found' }, 404);
if (position.creatorDid !== claims.did) return c.json({ error: 'Not your position' }, 403);
const now = Date.now();
// Handle status changes
if (body.status === 'paused' || body.status === 'withdrawn') {
// Cancel linked intents
ensureIntentsDoc(space);
ss().changeDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space), `LP ${body.status}: cancel linked intents`, (d) => {
if (d.intents[position.buyIntentId]) d.intents[position.buyIntentId].status = 'cancelled' as any;
if (d.intents[position.sellIntentId]) d.intents[position.sellIntentId].status = 'cancelled' as any;
});
}
ss().changeDoc<ExchangePoolsDoc>(exchangePoolsDocId(space), 'update liquidity position', (d) => {
const p = d.positions[id];
if (body.status) p.status = body.status as any;
if (body.spreadBps != null) p.spreadBps = body.spreadBps as any;
if (body.paymentMethods) p.paymentMethods = body.paymentMethods as any;
if (body.tokenCommitted != null) {
const delta = body.tokenCommitted - p.tokenCommitted;
p.tokenCommitted = body.tokenCommitted as any;
p.tokenRemaining = Math.max(0, p.tokenRemaining + delta) as any;
}
if (body.fiatCommitted != null) {
const delta = body.fiatCommitted - p.fiatCommitted;
p.fiatCommitted = body.fiatCommitted as any;
p.fiatRemaining = Math.max(0, p.fiatRemaining + delta) as any;
}
p.updatedAt = now as any;
});
// If updating spread or payment methods, also update linked intents
if ((body.spreadBps != null || body.paymentMethods) && body.status !== 'paused' && body.status !== 'withdrawn') {
ensureIntentsDoc(space);
ss().changeDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space), 'update LP linked intents', (d) => {
for (const intentId of [position.buyIntentId, position.sellIntentId]) {
const intent = d.intents[intentId];
if (!intent) continue;
if (body.spreadBps != null) intent.rateMarketBps = body.spreadBps as any;
if (body.paymentMethods) intent.paymentMethods = body.paymentMethods as any;
}
});
}
const updated = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
return c.json(updated.positions[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<ExchangeReputationDoc>(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<typeof setInterval> | 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<ExchangeIntentsDoc>(exchangeIntentsDocId(space));
const reputationDoc = syncServer.getDoc<ExchangeReputationDoc>(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<ExchangeTradesDoc>(tradesDocId);
if (!tradesDoc) {
tradesDoc = Automerge.change(Automerge.init<ExchangeTradesDoc>(), '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<ExchangeTradesDoc>(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<ExchangeTradesDoc>(tradesDocId)!;
const trade = created.trades[tradeId];
if (trade.acceptances[trade.buyerDid] && trade.acceptances[trade.sellerDid]) {
syncServer.changeDoc<ExchangeTradesDoc>(tradesDocId, 'auto-accept trade', (d) => {
d.trades[tradeId].status = 'accepted' as any;
});
syncServer.changeDoc<ExchangeIntentsDoc>(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<ExchangeTradesDoc>(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;
}
}