618 lines
24 KiB
TypeScript
618 lines
24 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,
|
|
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<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;
|
|
}
|
|
|
|
// ── 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]);
|
|
});
|
|
|
|
// ── 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;
|
|
}
|
|
}
|