feat(rexchange): add P2P crypto/fiat exchange module with escrow & reputation
CI/CD / deploy (push) Failing after 2m14s
Details
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:
parent
66f3957bc4
commit
cb95fdf850
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 — 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 → Match → 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" — 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 — 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="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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: {},
|
||||
}),
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue