feat(rexchange): add P2P crypto/fiat exchange module with escrow & reputation
CI/CD / deploy (push) Failing after 2m14s Details

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-04 18:32:02 -04:00
parent 66f3957bc4
commit cb95fdf850
12 changed files with 2402 additions and 0 deletions

View File

@ -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: {

372
lib/folk-exchange-node.ts Normal file
View File

@ -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<string, string> = {
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 = `
<span style="font-size:0.85rem;font-weight:600;color:${TEXT_COLOR}">💱 rExchange</span>
<span style="font-size:0.7rem;color:${MUTED_COLOR}" id="intent-count">0 intents</span>
`;
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 = `
<span style="font-size:0.7rem;color:${BUY_COLOR};display:flex;align-items:center;gap:4px">
<span style="width:8px;height:8px;border-radius:50%;background:${BUY_COLOR};display:inline-block"></span> Buy
</span>
<span style="font-size:0.7rem;color:${SELL_COLOR};display:flex;align-items:center;gap:4px">
<span style="width:8px;height:8px;border-radius:50%;background:${SELL_COLOR};display:inline-block"></span> Sell
</span>
<span style="font-size:0.7rem;color:${MATCH_ARC_COLOR};display:flex;align-items:center;gap:4px">
<span style="width:8px;height:1px;background:${MATCH_ARC_COLOR};display:inline-block"></span> Matched
</span>
`;
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);
}

View File

@ -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";

View File

@ -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<string, number>; // fiat currency → USD/fiat rate
ts: number;
}
const TTL = 5 * 60 * 1000;
let cached: RateEntry | null = null;
let inFlight: Promise<RateEntry> | 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<FiatCurrency, string> = {
EUR: 'eur', USD: 'usd', GBP: 'gbp', BRL: 'brl',
MXN: 'mxn', INR: 'inr', NGN: 'ngn', ARS: 'ars',
};
async function fetchRates(): Promise<RateEntry> {
if (cached && Date.now() - cached.ts < TTL) return cached;
if (inFlight) return inFlight;
inFlight = (async (): Promise<RateEntry> => {
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<number> {
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<Record<string, number>> {
const result: Record<string, number> = {};
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;
}

View File

@ -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<string, { creatorDid: string; isStandingOrder: boolean; status: string }> },
): boolean {
return Object.values(intentsDoc.intents).some(
i => i.creatorDid === did && i.isStandingOrder && i.status === 'active',
);
}

View File

@ -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<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;
}
}

View File

@ -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<ExchangeTradesDoc>(
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<ExchangeTradesDoc>(
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<ExchangeTradesDoc>(
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<ExchangeTradesDoc>(
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<ExchangeTradesDoc>(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<ExchangeIntentsDoc>(
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<ExchangeIntentsDoc>(
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<ExchangeReputationDoc>(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<ExchangeReputationDoc>(
exchangeReputationDocId(space),
'update dispute loser',
(d) => {
if (d.records[loserDid]) {
d.records[loserDid].disputesLost = (d.records[loserDid].disputesLost + 1) as any;
}
},
);
}

View File

@ -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<Omit<ExchangeTrade, 'id' | 'createdAt' | 'chatMessages'>[]> {
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<string, { buys: ExchangeIntent[]; sells: ExchangeIntent[] }>();
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<string>();
const results: Omit<ExchangeTrade, 'id' | 'createdAt' | 'chatMessages'>[] = [];
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,
};
}

View File

@ -0,0 +1,239 @@
/**
* rExchange landing page P2P crypto/fiat exchange within communities.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline" style="color:#f59e0b;background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.2)">
Part of the rSpace Ecosystem
</span>
<h1 class="rl-heading" style="background:linear-gradient(to right,#f59e0b,#10b981);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-size:2.5rem">
Community<br>Exchange
</h1>
<p class="rl-subtitle">
A <strong style="color:#e2e8f0">peer-to-peer on/off-ramp</strong> where community members
act as exchange nodes for each other. Buy and sell cUSDC, $MYCO, and fUSDC against
local fiat currencies &mdash; with escrow, reputation, and intent matching.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rexchange" class="rl-cta-primary"
style="background:linear-gradient(to right,#f59e0b,#10b981);color:white">
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
Try the Demo
</span>
</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>
<!-- ELI5 -->
<section class="rl-section" style="border-top:none">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2rem">
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem">ELI5</span>
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
What is P2P Exchange?
</h2>
<p style="font-size:1.05rem;color:#94a3b8;max-width:640px;margin:0.5rem auto 0">
Think <strong style="color:#f59e0b">LocalBitcoins</strong> inside your rSpace community.
Members post buy/sell intents, the solver finds matches, and escrow handles trustless settlement.
</p>
</div>
<div class="rl-grid-3">
<!-- Post Intent -->
<div class="rl-card" style="border:2px solid rgba(245,158,11,0.35);background:linear-gradient(to bottom right,rgba(245,158,11,0.08),rgba(245,158,11,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#f59e0b;display:flex;align-items:center;justify-content:center">
<span style="color:white;font-weight:700;font-size:0.9rem">$</span>
</div>
<h3 style="color:#fbbf24;font-size:1.05rem;margin-bottom:0">Post Intents</h3>
</div>
<p>
Buy or sell cUSDC, $MYCO, or fUSDC. Set your price, amount range, and accepted payment
methods (SEPA, Revolut, PIX, M-Pesa, Cash).
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Standing orders let you be a liquidity provider.</strong>
</p>
</div>
<!-- Automatic Matching -->
<div class="rl-card" style="border:2px solid rgba(16,185,129,0.35);background:linear-gradient(to bottom right,rgba(16,185,129,0.08),rgba(16,185,129,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#10b981;display:flex;align-items:center;justify-content:center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24"/></svg>
</div>
<h3 style="color:#34d399;font-size:1.05rem;margin-bottom:0">Solver Matching</h3>
</div>
<p>
The matching engine runs every 60s, finding compatible buy/sell pairs by token, currency,
rate overlap, and reputation. Scored and ranked automatically.
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Auto-accept for hands-free trading.</strong>
</p>
</div>
<!-- Escrow Settlement -->
<div class="rl-card" style="border:2px solid rgba(59,130,246,0.35);background:linear-gradient(to bottom right,rgba(59,130,246,0.08),rgba(59,130,246,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#3b82f6;display:flex;align-items:center;justify-content:center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<h3 style="color:#60a5fa;font-size:1.05rem;margin-bottom:0">Escrow & Trust</h3>
</div>
<p>
Seller's tokens are locked in CRDT escrow. Buyer sends fiat off-chain, seller confirms
receipt, tokens release. 24h timeout auto-reverses.
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Disputes resolved by space admins.</strong>
</p>
</div>
</div>
</div>
</section>
<!-- How It Works -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2.5rem">
<span class="rl-tagline" style="color:#f59e0b;background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.2)">
How It Works
</span>
<h2 class="rl-heading" style="background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Intent &rarr; Match &rarr; Settle
</h2>
</div>
<div class="rl-grid-3">
<div class="rl-card" style="border-color:rgba(245,158,11,0.2)">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:9999px;background:linear-gradient(to bottom right,#f59e0b,rgba(245,158,11,0.6));display:flex;align-items:center;justify-content:center">
<span style="color:white;font-weight:700;font-size:1rem">1</span>
</div>
<div>
<span class="rl-badge" style="background:rgba(245,158,11,0.1);color:#fbbf24;margin-bottom:0.25rem">Step 1</span>
<h3 style="margin-bottom:0;font-size:1rem">Post Intent</h3>
</div>
</div>
<p>
"I want to buy 100 cUSDC for EUR at market rate via SEPA" &mdash; or sell, at a fixed
price, with any payment method. Set reputation thresholds and amount ranges.
</p>
</div>
<div class="rl-card" style="border-color:rgba(16,185,129,0.25);background:linear-gradient(to bottom right,rgba(16,185,129,0.05),rgba(245,158,11,0.03))">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:9999px;background:linear-gradient(to bottom right,#10b981,#f59e0b);display:flex;align-items:center;justify-content:center">
<span style="color:white;font-weight:700;font-size:1rem">2</span>
</div>
<div>
<span class="rl-badge" style="background:rgba(16,185,129,0.15);color:#34d399;margin-bottom:0.25rem">Step 2</span>
<h3 style="margin-bottom:0;font-size:1rem">Match & Escrow</h3>
</div>
</div>
<p>
Solver finds your counterparty. Both accept the match. Seller's tokens lock in escrow.
In-trade chat for coordinating fiat payment details.
</p>
</div>
<div class="rl-card" style="border-color:rgba(59,130,246,0.2)">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:9999px;background:linear-gradient(to bottom right,#3b82f6,#059669);display:flex;align-items:center;justify-content:center">
<span style="color:white;font-weight:700;font-size:1rem">3</span>
</div>
<div>
<span class="rl-badge" style="background:rgba(59,130,246,0.1);color:#60a5fa;margin-bottom:0.25rem">Step 3</span>
<h3 style="margin-bottom:0;font-size:1rem">Settle & Build Rep</h3>
</div>
</div>
<p>
Buyer sends fiat, seller confirms receipt, tokens release instantly. Both parties earn
reputation score. Standing orders re-activate for continuous liquidity.
</p>
</div>
</div>
</div>
</section>
<!-- Features -->
<section class="rl-section">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2rem">
<h2 class="rl-heading" style="background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Built for Global Communities
</h2>
</div>
<div class="rl-grid-4">
<div class="rl-card rl-card--center" style="border-color:rgba(245,158,11,0.15)">
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#f59e0b,#d97706);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
<span style="font-size:1.25rem">🌍</span>
</div>
<h3>8 Fiat Currencies</h3>
<p>EUR, USD, GBP, BRL, MXN, INR, NGN, ARS with CoinGecko live rates.</p>
</div>
<div class="rl-card rl-card--center" style="border-color:rgba(16,185,129,0.15)">
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#10b981,#059669);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
<span style="font-size:1.25rem">🔒</span>
</div>
<h3>CRDT Escrow</h3>
<p>Trustless token escrow with 24h timeout. Net supply neutral settlement.</p>
</div>
<div class="rl-card rl-card--center" style="border-color:rgba(59,130,246,0.15)">
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#3b82f6,#2563eb);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
<span style="font-size:1.25rem"></span>
</div>
<h3>Reputation</h3>
<p>Score based on completion rate, dispute history, and confirmation speed. Badges earned.</p>
</div>
<div class="rl-card rl-card--center" style="border-color:rgba(236,72,153,0.15)">
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#ec4899,#db2777);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
<span style="font-size:1.25rem">🤖</span>
</div>
<h3>Auto-Matching</h3>
<p>Intent solver runs every 60s. Auto-accept for hands-free LP trading.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="rl-section">
<div class="rl-container">
<div class="rl-card" style="border:2px solid rgba(245,158,11,0.25);background:linear-gradient(to bottom right,rgba(245,158,11,0.08),rgba(16,185,129,0.04));text-align:center;padding:3rem 2rem;position:relative;overflow:hidden">
<span class="rl-badge" style="background:rgba(245,158,11,0.1);color:#fbbf24;font-size:0.7rem;padding:0.25rem 0.75rem">
Join the rSpace Ecosystem
</span>
<h2 style="font-size:1.75rem;font-weight:700;color:#e2e8f0;margin:1rem 0">
Ready to exchange with your community?
</h2>
<p style="font-size:1.05rem;color:#94a3b8;max-width:560px;margin:0 auto 2rem;line-height:1.6">
Create a Space and become a liquidity node. Post standing orders to provide exchange
access for your community &mdash; earn reputation and enable financial inclusion.
</p>
<div class="rl-cta-row" style="margin-top:0">
<a href="/create-space" class="rl-cta-primary"
style="background:linear-gradient(to right,#f59e0b,#10b981);color:white">
<span style="display:inline-flex;align-items:center;gap:0.5rem">
Create a Space
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</span>
</a>
<a href="https://demo.rspace.online/rexchange" class="rl-cta-secondary">
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
Interactive Demo
</span>
</a>
</div>
</div>
</div>
</section>
<div class="rl-back">
<a href="/">&larr; Back to rSpace</a>
</div>`;
}

86
modules/rexchange/mod.ts Normal file
View File

@ -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: `<folk-exchange-app space="${space}"></folk-exchange-app>`,
scripts: `<script type="module" src="/modules/rexchange/folk-exchange-app.js?v=1"></script>`,
}));
});
// ── 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' },
],
};

View File

@ -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<string, boolean>; // 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<string, ExchangeIntent>;
}
export interface ExchangeTradesDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
trades: Record<string, ExchangeTrade>;
}
export interface ExchangeReputationDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
records: Record<string, ExchangeReputationRecord>;
}
// ── 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<ExchangeIntentsDoc> = {
module: 'rexchange',
collection: 'intents',
version: 1,
init: (): ExchangeIntentsDoc => ({
meta: {
module: 'rexchange',
collection: 'intents',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
intents: {},
}),
};
export const exchangeTradesSchema: DocSchema<ExchangeTradesDoc> = {
module: 'rexchange',
collection: 'trades',
version: 1,
init: (): ExchangeTradesDoc => ({
meta: {
module: 'rexchange',
collection: 'trades',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
trades: {},
}),
};
export const exchangeReputationSchema: DocSchema<ExchangeReputationDoc> = {
module: 'rexchange',
collection: 'reputation',
version: 1,
init: (): ExchangeReputationDoc => ({
meta: {
module: 'rexchange',
collection: 'reputation',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
records: {},
}),
};

View File

@ -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);