From 0e2eebf890b1ef906f5844a6bb789f7f18744039 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 00:58:04 +0000 Subject: [PATCH 1/8] feat(encryptid): add wallet lookup + payment notification APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add internal endpoints for payment infrastructure integration: - GET /api/internal/user-by-wallet — resolve wallet to email/username - POST /api/internal/notify — trigger in-app notifications by wallet/DID - Add 'payment' notification category and payment_sent/received event types Co-Authored-By: Claude Opus 4.6 (1M context) --- server/notification-service.ts | 6 ++-- src/encryptid/server.ts | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/server/notification-service.ts b/server/notification-service.ts index b9b7a77..ef74878 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -67,7 +67,7 @@ async function getSmtpTransport() { // TYPES // ============================================================================ -export type NotificationCategory = 'space' | 'module' | 'system' | 'social'; +export type NotificationCategory = 'space' | 'module' | 'system' | 'social' | 'payment'; export type NotificationEventType = // Space @@ -85,7 +85,9 @@ export type NotificationEventType = // Delegation | 'delegation_received' | 'delegation_revoked' | 'delegation_expired' // Commitment (rTime) - | 'commitment_requested' | 'commitment_accepted' | 'commitment_declined'; + | 'commitment_requested' | 'commitment_accepted' | 'commitment_declined' + // Payment + | 'payment_sent' | 'payment_received' | 'payment_request_fulfilled'; export interface NotifyOptions { userDid: string; diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 2bf5b6c..096de10 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -4260,6 +4260,66 @@ app.get('/api/internal/user-by-email', async (c) => { }); }); +// GET /api/internal/user-by-wallet — look up user by wallet address +app.get('/api/internal/user-by-wallet', async (c) => { + const serviceKey = c.req.header('X-Service-Key'); + if (!INTERNAL_SERVICE_KEY || serviceKey !== INTERNAL_SERVICE_KEY) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const wallet = c.req.query('wallet'); + if (!wallet) return c.json({ found: false }, 200); + + const normalizedWallet = wallet.toLowerCase().trim(); + // Look up user by wallet_address field in users table + const rows = await sql`SELECT * FROM users WHERE LOWER(wallet_address) = ${normalizedWallet} LIMIT 1`; + if (rows.length === 0) return c.json({ found: false }, 200); + + const profile = await getUserProfile(rows[0].id); + return c.json({ + found: true, + email: profile?.profileEmail || rows[0].email || undefined, + username: profile?.username || rows[0].username || undefined, + userId: rows[0].id, + did: rows[0].did || undefined, + }); +}); + +// POST /api/internal/notify — trigger in-app notification for a user by DID or wallet +app.post('/api/internal/notify', async (c) => { + const serviceKey = c.req.header('X-Service-Key'); + if (!INTERNAL_SERVICE_KEY || serviceKey !== INTERNAL_SERVICE_KEY) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const { userDid, wallet, category, eventType, title, body, actionUrl, actorUsername, metadata } = await c.req.json(); + + // Resolve DID from wallet if not provided + let resolvedDid = userDid; + if (!resolvedDid && wallet) { + const normalizedWallet = wallet.toLowerCase().trim(); + const rows = await sql`SELECT did FROM users WHERE LOWER(wallet_address) = ${normalizedWallet} LIMIT 1`; + if (rows.length > 0) resolvedDid = rows[0].did; + } + + if (!resolvedDid) { + return c.json({ error: 'Could not resolve user — provide userDid or a registered wallet' }, 400); + } + + const notification = await notify({ + userDid: resolvedDid, + category: category || 'payment', + eventType: eventType || 'payment_received', + title: title || 'Payment received', + body, + actionUrl, + actorUsername, + metadata, + }); + + return c.json({ success: true, notificationId: notification.id }); +}); + // ============================================================================ // USER LOOKUP // ============================================================================ From 5b4ab00a2dd4256bf7d6e9850b1e09928b13c334 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 01:41:08 +0000 Subject: [PATCH 2/8] fix(rgov): render canvas directly instead of redirecting to /rspace The rgov module page was showing a static description with a link to /rspace. Now it renders the actual canvas (same as rspace module) with moduleId="rgov" so GovMod shapes display inline at /rgov. Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/rgov/mod.ts | 74 ++++++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts index 8320c08..47bdd51 100644 --- a/modules/rgov/mod.ts +++ b/modules/rgov/mod.ts @@ -8,6 +8,7 @@ */ import { Hono } from "hono"; +import { resolve } from "path"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -16,10 +17,53 @@ import { addShapes, getDocumentData } from "../../server/community-store"; const routes = new Hono(); -// ── Module page (within a space) ── +// ── Canvas content loader (same approach as rspace module) ── -routes.get("/", (c) => { +const DIST_DIR = resolve(import.meta.dir, "../../dist"); +let canvasCache: { body: string; styles: string; scripts: string } | null = null; + +function extractCanvasContent(html: string) { + const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); + const styleMatches = [...html.matchAll(/]*>([\s\S]*?)<\/style>/gi)]; + const scriptMatches = [...html.matchAll(/]*>[\s\S]*?<\/script>/gi)]; + return { + body: bodyMatch?.[1] || "", + styles: styleMatches.map(m => m[0]).join("\n"), + scripts: scriptMatches.map(m => m[0]).join("\n"), + }; +} + +async function getCanvasContent() { + if (canvasCache) return canvasCache; + + const moduleFile = Bun.file(resolve(DIST_DIR, "canvas-module.html")); + if (await moduleFile.exists()) { + canvasCache = { + body: await moduleFile.text(), + styles: "", + scripts: ``, + }; + return canvasCache; + } + + const fullFile = Bun.file(resolve(DIST_DIR, "canvas.html")); + if (await fullFile.exists()) { + canvasCache = extractCanvasContent(await fullFile.text()); + return canvasCache; + } + + return { + body: `
Canvas loading...
`, + styles: "", + scripts: "", + }; +} + +// ── Module page (within a space) — renders canvas directly ── + +routes.get("/", async (c) => { const space = c.req.param("space") || "demo"; + const canvas = await getCanvasContent(); return c.html(renderShell({ title: `${space} — rGov | rSpace`, @@ -27,29 +71,9 @@ routes.get("/", (c) => { spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - body: ` -
-

⚖️ rGov — GovMods

-

Do-ocratic circuit components for multiplayer collaboration

-

- Build governance decision circuits by wiring GovMods together on the canvas: -

-
    -
  • Signoff Gates — Yes/No approval checkpoints
  • -
  • Thresholds — Numeric targets (hours, dollars, signatures)
  • -
  • Knobs — Tunable parameters with temporal viscosity
  • -
  • Projects — Circuit aggregators showing "X of Y gates satisfied"
  • -
  • Amendments — Propose in-place circuit modifications
  • -
  • Quadratic Transform — Weight dampening (sqrt/log) for fair voting
  • -
  • Conviction Accumulator — Time-weighted conviction scoring
  • -
  • Multisig Gate — M-of-N approval multiplexor
  • -
  • Sankey Visualizer — Auto-discovered governance flow diagram
  • -
- - Open Canvas → - -
- `, + body: canvas.body, + styles: canvas.styles, + scripts: canvas.scripts, })); }); From 2e2fbae8bf261d10ac9cc36a2342e306cd829fc0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 01:55:52 +0000 Subject: [PATCH 3/8] feat(rgov): standalone n8n-style GovMod circuit canvas Add component with interactive SVG canvas pre-loaded with 3 demo governance circuits. 8 node types (signoff, threshold, knob, project, quadratic, conviction, multisig, sankey) with pan/zoom, node dragging, Bezier wiring, palette sidebar, and detail panel. Compatible with rspace canvas shape types for rapplet integration. Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/rgov/components/folk-gov-circuit.ts | 1555 +++++++++++++++++++ modules/rgov/mod.ts | 53 +- vite.config.ts | 21 + 3 files changed, 1580 insertions(+), 49 deletions(-) create mode 100644 modules/rgov/components/folk-gov-circuit.ts diff --git a/modules/rgov/components/folk-gov-circuit.ts b/modules/rgov/components/folk-gov-circuit.ts new file mode 100644 index 0000000..924c185 --- /dev/null +++ b/modules/rgov/components/folk-gov-circuit.ts @@ -0,0 +1,1555 @@ +/** + * — n8n-style interactive mini-canvas for governance + * decision circuits in rGov. + * + * Renders governance-specific workflow nodes (signoff, threshold, knob, + * project, quadratic, conviction, multisig, sankey) on an SVG canvas + * with ports, Bezier wiring, node palette, detail panel, and fit-view. + * + * Standalone canvas — no Automerge, no server calls. Pure client-side + * with pre-loaded demo data. + * + * Attributes: + * circuit — which demo circuit to show (default: all) + */ + +// ── Types ── + +interface GovNodeDef { + type: string; + label: string; + icon: string; + color: string; + inputs: { name: string }[]; + outputs: { name: string }[]; +} + +interface GovNode { + id: string; + type: string; + label: string; + position: { x: number; y: number }; + config: Record; +} + +interface GovEdge { + id: string; + fromNode: string; + fromPort: string; + toNode: string; + toPort: string; +} + +// ── Constants ── + +const NODE_WIDTH = 240; +const NODE_HEIGHT = 120; +const PORT_RADIUS = 6; + +const GOV_NODE_CATALOG: GovNodeDef[] = [ + { + type: 'folk-gov-binary', + label: 'Signoff', + icon: '\u2714', + color: '#7c3aed', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-threshold', + label: 'Threshold', + icon: '\u2593', + color: '#0891b2', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-knob', + label: 'Knob', + icon: '\u2699', + color: '#b45309', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-project', + label: 'Project', + icon: '\u25A3', + color: '#10b981', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-quadratic', + label: 'Quadratic', + icon: '\u221A', + color: '#14b8a6', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-conviction', + label: 'Conviction', + icon: '\u23F1', + color: '#d97706', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-multisig', + label: 'Multisig', + icon: '\u{1F511}', + color: '#6366f1', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, + { + type: 'folk-gov-sankey', + label: 'Sankey', + icon: '\u2B82', + color: '#f43f5e', + inputs: [{ name: 'in' }], + outputs: [{ name: 'out' }], + }, +]; + +// ── Demo Data ── + +function buildDemoData(): { nodes: GovNode[]; edges: GovEdge[] } { + const nodes: GovNode[] = []; + const edges: GovEdge[] = []; + let eid = 0; + const mkEdge = (from: string, fp: string, to: string, tp: string) => { + edges.push({ id: `ge-${++eid}`, fromNode: from, fromPort: fp, toNode: to, toPort: tp }); + }; + + // ── Circuit 1: Build a Climbing Wall ── + const c1y = 40; + nodes.push({ + id: 'c1-labor', type: 'folk-gov-threshold', label: 'Labor Threshold', + position: { x: 50, y: c1y }, + config: { target: 50, current: 20, unit: 'hours', contributors: 'Alice: 8h, Bob: 6h, Carol: 6h' }, + }); + nodes.push({ + id: 'c1-capital', type: 'folk-gov-threshold', label: 'Capital Threshold', + position: { x: 50, y: c1y + 160 }, + config: { target: 3000, current: 2000, unit: '$', contributors: 'Fund A: $1200, Dave: $800' }, + }); + nodes.push({ + id: 'c1-signoff', type: 'folk-gov-binary', label: 'Proprietor Signoff', + position: { x: 50, y: c1y + 320 }, + config: { assignee: 'Landlord (pending)', satisfied: false }, + }); + nodes.push({ + id: 'c1-project', type: 'folk-gov-project', label: 'Build a Climbing Wall', + position: { x: 400, y: c1y + 140 }, + config: { description: 'Community climbing wall in the courtyard', gatesSatisfied: 1, gatesTotal: 3 }, + }); + mkEdge('c1-labor', 'out', 'c1-project', 'in'); + mkEdge('c1-capital', 'out', 'c1-project', 'in'); + mkEdge('c1-signoff', 'out', 'c1-project', 'in'); + + // ── Circuit 2: Community Potluck ── + const c2y = c1y + 520; + nodes.push({ + id: 'c2-budget', type: 'folk-gov-knob', label: 'Budget Knob', + position: { x: 50, y: c2y }, + config: { min: 100, max: 5000, value: 1500, unit: '$' }, + }); + nodes.push({ + id: 'c2-rsvps', type: 'folk-gov-threshold', label: 'RSVPs', + position: { x: 50, y: c2y + 160 }, + config: { target: 20, current: 14, unit: 'people', contributors: '14 confirmed attendees' }, + }); + nodes.push({ + id: 'c2-venue', type: 'folk-gov-binary', label: 'Venue Signoff', + position: { x: 50, y: c2y + 320 }, + config: { assignee: 'Carlos', satisfied: true }, + }); + nodes.push({ + id: 'c2-project', type: 'folk-gov-project', label: 'Community Potluck', + position: { x: 400, y: c2y + 140 }, + config: { description: 'Monthly community potluck event', gatesSatisfied: 2, gatesTotal: 3 }, + }); + mkEdge('c2-budget', 'out', 'c2-project', 'in'); + mkEdge('c2-rsvps', 'out', 'c2-project', 'in'); + mkEdge('c2-venue', 'out', 'c2-project', 'in'); + + // ── Circuit 3: Delegated Budget Approval ── + const c3y = c2y + 520; + nodes.push({ + id: 'c3-quad', type: 'folk-gov-quadratic', label: 'Quadratic Dampener', + position: { x: 50, y: c3y }, + config: { mode: 'sqrt', entries: 'Alice: 100, Bob: 49, Carol: 25' }, + }); + nodes.push({ + id: 'c3-conviction', type: 'folk-gov-conviction', label: 'Conviction Accumulator', + position: { x: 50, y: c3y + 160 }, + config: { threshold: 500, accumulated: 320, stakes: 'Alice: 150, Bob: 100, Carol: 70' }, + }); + nodes.push({ + id: 'c3-multisig', type: 'folk-gov-multisig', label: '3-of-5 Multisig', + position: { x: 50, y: c3y + 320 }, + config: { required: 3, total: 5, signers: 'Alice, Bob, Carol, Dave, Eve', signed: 'Alice, Bob' }, + }); + nodes.push({ + id: 'c3-project', type: 'folk-gov-project', label: 'Delegated Budget Approval', + position: { x: 400, y: c3y + 140 }, + config: { description: 'Multi-mechanism budget governance', gatesSatisfied: 0, gatesTotal: 3 }, + }); + nodes.push({ + id: 'c3-sankey', type: 'folk-gov-sankey', label: 'Flow Visualizer', + position: { x: 700, y: c3y + 140 }, + config: { note: 'Decorative flow diagram' }, + }); + mkEdge('c3-quad', 'out', 'c3-project', 'in'); + mkEdge('c3-conviction', 'out', 'c3-project', 'in'); + mkEdge('c3-multisig', 'out', 'c3-project', 'in'); + + return { nodes, edges }; +} + +// ── Helpers ── + +function esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s || ''; + return d.innerHTML; +} + +function getNodeDef(type: string): GovNodeDef | undefined { + return GOV_NODE_CATALOG.find(n => n.type === type); +} + +function getPortX(node: GovNode, _portName: string, direction: 'input' | 'output'): number { + return direction === 'input' ? node.position.x : node.position.x + NODE_WIDTH; +} + +function getPortY(node: GovNode, _portName: string, direction: 'input' | 'output'): number { + const def = getNodeDef(node.type); + if (!def) return node.position.y + NODE_HEIGHT / 2; + const ports = direction === 'input' ? def.inputs : def.outputs; + const idx = ports.findIndex(p => p.name === _portName); + if (idx === -1) return node.position.y + NODE_HEIGHT / 2; + const spacing = NODE_HEIGHT / (ports.length + 1); + return node.position.y + spacing * (idx + 1); +} + +function bezierPath(x1: number, y1: number, x2: number, y2: number): string { + const dx = Math.abs(x2 - x1) * 0.5; + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; +} + +// ── Node body renderers ── + +function renderNodeBody(node: GovNode): string { + const c = node.config; + switch (node.type) { + case 'folk-gov-binary': { + const satisfied = c.satisfied ? 'Yes' : 'No'; + const icon = c.satisfied + ? '' + : ''; + return ` +
Assignee: ${esc(c.assignee || 'Unassigned')}
+
+ ${icon} + ${satisfied} +
`; + } + case 'folk-gov-threshold': { + const pct = c.target > 0 ? Math.min(100, Math.round((c.current / c.target) * 100)) : 0; + return ` +
+ ${c.current}/${c.target} ${esc(c.unit || '')} + ${pct}% +
+
+
+
`; + } + case 'folk-gov-knob': { + const pct = c.max > c.min ? Math.round(((c.value - c.min) / (c.max - c.min)) * 100) : 50; + return ` +
+ ${esc(c.unit || '')}${c.min} \u2014 ${esc(c.unit || '')}${c.max} +
+
+
+
+
+
+ ${esc(c.unit || '')}${c.value} +
`; + } + case 'folk-gov-project': { + const sat = c.gatesSatisfied || 0; + const tot = c.gatesTotal || 0; + return ` +
${esc(c.description || '')}
+
+ ${sat} of ${tot} gates satisfied + ${sat >= tot ? '' : ''} +
`; + } + case 'folk-gov-quadratic': { + const mode = c.mode || 'sqrt'; + return ` +
Mode: ${esc(mode)}
+
${esc(c.entries || '')}
`; + } + case 'folk-gov-conviction': { + const pct = c.threshold > 0 ? Math.min(100, Math.round((c.accumulated / c.threshold) * 100)) : 0; + return ` +
+ Score: ${c.accumulated}/${c.threshold} + ${pct}% +
+
+
+
`; + } + case 'folk-gov-multisig': { + const signed = (c.signed || '').split(',').map((s: string) => s.trim()).filter(Boolean); + const all = (c.signers || '').split(',').map((s: string) => s.trim()).filter(Boolean); + const checks = all.map((s: string) => { + const ok = signed.includes(s); + return `${ok ? '\u2714' : '\u25CB'}`; + }).join(' '); + return ` +
${c.required} of ${c.total} required
+
${checks}
`; + } + case 'folk-gov-sankey': { + return ` +
Flow visualization
+
+
+
+
+
+
+
`; + } + default: + return ''; + } +} + +// ── Styles ── + +const STYLES = ` +:host { + display: block; + width: 100%; + height: 100%; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + color: #e2e8f0; +} + +.gc-root { + display: flex; + flex-direction: column; + height: 100%; + background: #0f172a; +} + +/* ── Toolbar ── */ + +.gc-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: #1e293b; + border-bottom: 1px solid #334155; + flex-shrink: 0; + gap: 12px; +} + +.gc-toolbar__title { + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + font-weight: 600; + color: #f1f5f9; +} + +.gc-toolbar__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.gc-btn { + padding: 4px 12px; + border: 1px solid #334155; + border-radius: 6px; + background: #1e293b; + color: #cbd5e1; + font-size: 12px; + cursor: pointer; + transition: background 0.15s; +} + +.gc-btn:hover { + background: #334155; +} + +.gc-btn--fit { + color: #38bdf8; + border-color: #38bdf8; +} + +/* ── Canvas area ── */ + +.gc-canvas-area { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +/* ── Palette ── */ + +.gc-palette { + width: 180px; + flex-shrink: 0; + background: #1e293b; + border-right: 1px solid #334155; + overflow-y: auto; + padding: 12px 8px; +} + +.gc-palette__title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin-bottom: 8px; +} + +.gc-palette__card { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid #334155; + background: #0f172a; + margin-bottom: 4px; + cursor: grab; + transition: border-color 0.15s, background 0.15s; + font-size: 12px; +} + +.gc-palette__card:hover { + border-color: #475569; + background: #1e293b; +} + +.gc-palette__card-color { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.gc-palette__card-label { + color: #cbd5e1; +} + +/* ── SVG Canvas ── */ + +.gc-canvas { + flex: 1; + position: relative; + overflow: hidden; + cursor: grab; +} + +.gc-canvas.grabbing { + cursor: grabbing; +} + +.gc-canvas.wiring { + cursor: crosshair; +} + +.gc-canvas svg { + width: 100%; + height: 100%; + display: block; +} + +/* ── Grid ── */ + +.gc-grid-pattern line { + stroke: #1e293b; + stroke-width: 1; +} + +/* ── Zoom controls ── */ + +.gc-zoom-controls { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 0; + background: #1e293b; + border: 1px solid #334155; + border-radius: 8px; + padding: 2px 4px; +} + +.gc-zoom-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + border-radius: 4px; +} + +.gc-zoom-btn:hover { + background: #334155; + color: #e2e8f0; +} + +.gc-zoom-level { + font-size: 11px; + color: #64748b; + min-width: 40px; + text-align: center; + user-select: none; +} + +.gc-zoom-sep { + width: 1px; + height: 16px; + background: #334155; + margin: 0 2px; +} + +/* ── Edges ── */ + +.gc-edge-path { + fill: none; + stroke-width: 2; + pointer-events: none; +} + +.gc-edge-hit { + fill: none; + stroke: transparent; + stroke-width: 12; + cursor: pointer; +} + +.gc-edge-hit:hover + .gc-edge-path { + stroke-opacity: 1; + stroke-width: 3; +} + +/* ── Wiring temp ── */ + +.gc-wiring-temp { + fill: none; + stroke: #38bdf8; + stroke-width: 2; + stroke-dasharray: 6 4; + pointer-events: none; +} + +/* ── Ports ── */ + +.gc-port-dot { + transition: r 0.1s; +} + +.gc-port-hit { + cursor: crosshair; +} + +.gc-port-hit:hover ~ .gc-port-dot, +.gc-port-group:hover .gc-port-dot { + r: 8; +} + +/* ── Detail panel ── */ + +.gc-detail { + width: 0; + overflow: hidden; + background: #1e293b; + border-left: 1px solid #334155; + transition: width 0.2s; + flex-shrink: 0; +} + +.gc-detail.open { + width: 280px; +} + +.gc-detail__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #334155; + font-size: 13px; + font-weight: 600; +} + +.gc-detail__close { + background: none; + border: none; + color: #64748b; + font-size: 18px; + cursor: pointer; + padding: 0 4px; +} + +.gc-detail__close:hover { + color: #e2e8f0; +} + +.gc-detail__body { + padding: 12px 16px; + overflow-y: auto; + max-height: calc(100% - 48px); +} + +.gc-detail__field { + margin-bottom: 12px; +} + +.gc-detail__field label { + display: block; + font-size: 11px; + color: #64748b; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.gc-detail__field input, +.gc-detail__field textarea, +.gc-detail__field select { + width: 100%; + padding: 6px 8px; + border: 1px solid #334155; + border-radius: 4px; + background: #0f172a; + color: #e2e8f0; + font-size: 12px; + box-sizing: border-box; +} + +.gc-detail__field textarea { + resize: vertical; + min-height: 60px; +} + +.gc-detail__delete { + width: 100%; + padding: 8px; + margin-top: 12px; + border: 1px solid #7f1d1d; + border-radius: 6px; + background: #450a0a; + color: #fca5a5; + font-size: 12px; + cursor: pointer; +} + +.gc-detail__delete:hover { + background: #7f1d1d; +} +`; + +// ── Component ── + +export class FolkGovCircuit extends HTMLElement { + private shadow: ShadowRoot; + + // Data + private nodes: GovNode[] = []; + private edges: GovEdge[] = []; + + // Canvas state + private canvasZoom = 1; + private canvasPanX = 0; + private canvasPanY = 0; + private showGrid = true; + + // Interaction + private isPanning = false; + private panStartX = 0; + private panStartY = 0; + private panStartPanX = 0; + private panStartPanY = 0; + private draggingNodeId: string | null = null; + private dragStartX = 0; + private dragStartY = 0; + private dragNodeStartX = 0; + private dragNodeStartY = 0; + + // Selection & detail panel + private selectedNodeId: string | null = null; + private detailOpen = false; + + // Wiring + private wiringActive = false; + private wiringSourceNodeId: string | null = null; + private wiringSourcePortName: string | null = null; + private wiringPointerX = 0; + private wiringPointerY = 0; + + // Bound listeners + private _boundPointerMove: ((e: PointerEvent) => void) | null = null; + private _boundPointerUp: ((e: PointerEvent) => void) | null = null; + private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + static get observedAttributes() { return ['circuit']; } + + connectedCallback() { + this.initData(); + } + + disconnectedCallback() { + if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); + if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); + if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); + } + + // ── Data init ── + + private initData() { + const demo = buildDemoData(); + this.nodes = demo.nodes; + this.edges = demo.edges; + this.render(); + requestAnimationFrame(() => this.fitView()); + } + + // ── Canvas transform ── + + private updateCanvasTransform() { + const g = this.shadow.getElementById('canvas-transform'); + if (g) g.setAttribute('transform', `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`); + this.updateZoomDisplay(); + } + + private updateZoomDisplay() { + const el = this.shadow.getElementById('zoom-level'); + if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`; + } + + private fitView() { + const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null; + if (!svg || this.nodes.length === 0) return; + const rect = svg.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of this.nodes) { + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + maxX = Math.max(maxX, n.position.x + NODE_WIDTH); + maxY = Math.max(maxY, n.position.y + NODE_HEIGHT); + } + + const pad = 60; + const contentW = maxX - minX + pad * 2; + const contentH = maxY - minY + pad * 2; + const scaleX = rect.width / contentW; + const scaleY = rect.height / contentH; + this.canvasZoom = Math.min(scaleX, scaleY, 1.5); + this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom; + this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom; + this.updateCanvasTransform(); + } + + private zoomAt(screenX: number, screenY: number, factor: number) { + const oldZoom = this.canvasZoom; + const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor)); + this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom); + this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom); + this.canvasZoom = newZoom; + this.updateCanvasTransform(); + } + + // ── Rendering ── + + private render() { + const gridDef = this.showGrid ? ` + + + + + + + ` : ''; + + this.shadow.innerHTML = ` + +
+
+
+ \u25A3 Governance Circuits +
+
+ + + + +
+
+ +
+
+
Node Types
+ ${GOV_NODE_CATALOG.map(n => ` +
+
+ ${n.icon} ${esc(n.label)} +
+ `).join('')} +
+ +
+ + + ${gridDef} + ${this.renderAllEdges()} + + ${this.renderAllNodes()} + + +
+ + + ${Math.round(this.canvasZoom * 100)}% + + + + +
+
+ +
+ ${this.renderDetailPanel()} +
+
+
+ `; + + this.attachEventListeners(); + } + + private renderAllNodes(): string { + return this.nodes.map(node => this.renderNode(node)).join(''); + } + + private renderNode(node: GovNode): string { + const def = getNodeDef(node.type); + if (!def) return ''; + const isSelected = node.id === this.selectedNodeId; + + // Ports + let portsHtml = ''; + for (const inp of def.inputs) { + const y = getPortY(node, inp.name, 'input'); + const x = node.position.x; + portsHtml += ` + + + + `; + } + for (const out of def.outputs) { + const y = getPortY(node, out.name, 'output'); + const x = node.position.x + NODE_WIDTH; + portsHtml += ` + + + + `; + } + + const bodyHtml = renderNodeBody(node); + + return ` + + +
+
+
+
+ ${def.icon} + ${esc(node.label)} +
+ ${bodyHtml} +
+
+
+ ${portsHtml} +
`; + } + + private renderAllEdges(): string { + return this.edges.map(edge => { + const fromNode = this.nodes.find(n => n.id === edge.fromNode); + const toNode = this.nodes.find(n => n.id === edge.toNode); + if (!fromNode || !toNode) return ''; + + const x1 = getPortX(fromNode, edge.fromPort, 'output'); + const y1 = getPortY(fromNode, edge.fromPort, 'output'); + const x2 = getPortX(toNode, edge.toPort, 'input'); + const y2 = getPortY(toNode, edge.toPort, 'input'); + + const fromDef = getNodeDef(fromNode.type); + const color = fromDef ? fromDef.color : '#6b7280'; + const d = bezierPath(x1, y1, x2, y2); + + return ` + + + + `; + }).join(''); + } + + private renderDetailPanel(): string { + if (!this.selectedNodeId) { + return ` +
+ No node selected + +
+
+

Click a node to view details.

+
`; + } + + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (!node) return ''; + const def = getNodeDef(node.type); + if (!def) return ''; + + const fieldsHtml = this.renderDetailFields(node); + + return ` +
+ ${def.icon} ${esc(node.label)} + +
+
+
+ + +
+
+ + +
+ ${fieldsHtml} + +
`; + } + + private renderDetailFields(node: GovNode): string { + const c = node.config; + switch (node.type) { + case 'folk-gov-binary': + return ` +
+ + +
+
+ + +
`; + case 'folk-gov-threshold': + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-knob': + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-project': + return ` +
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-quadratic': + return ` +
+ + +
+
+ + +
`; + case 'folk-gov-conviction': + return ` +
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-multisig': + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
`; + case 'folk-gov-sankey': + return ` +
+ + +
`; + default: + return ''; + } + } + + // ── Redraw helpers ── + + private drawCanvasContent() { + const edgeLayer = this.shadow.getElementById('edge-layer'); + const nodeLayer = this.shadow.getElementById('node-layer'); + const wireLayer = this.shadow.getElementById('wire-layer'); + if (!edgeLayer || !nodeLayer) return; + edgeLayer.innerHTML = this.renderAllEdges(); + nodeLayer.innerHTML = this.renderAllNodes(); + if (wireLayer) wireLayer.innerHTML = ''; + } + + private redrawEdges() { + const edgeLayer = this.shadow.getElementById('edge-layer'); + if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); + } + + private updateNodePosition(node: GovNode) { + const nodeLayer = this.shadow.getElementById('node-layer'); + if (!nodeLayer) return; + const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; + if (!g) return; + const fo = g.querySelector('foreignObject'); + if (fo) { + fo.setAttribute('x', String(node.position.x)); + fo.setAttribute('y', String(node.position.y)); + } + const def = getNodeDef(node.type); + if (!def) return; + const portGroups = g.querySelectorAll('.gc-port-group'); + portGroups.forEach(pg => { + const portName = (pg as HTMLElement).dataset.portName!; + const dir = (pg as HTMLElement).dataset.portDir as 'input' | 'output'; + const x = dir === 'input' ? node.position.x : node.position.x + NODE_WIDTH; + const ports = dir === 'input' ? def.inputs : def.outputs; + const idx = ports.findIndex(p => p.name === portName); + const spacing = NODE_HEIGHT / (ports.length + 1); + const y = node.position.y + spacing * (idx + 1); + pg.querySelectorAll('circle').forEach(c => { + c.setAttribute('cx', String(x)); + c.setAttribute('cy', String(y)); + }); + }); + } + + private refreshDetailPanel() { + const panel = this.shadow.getElementById('detail-panel'); + if (!panel) return; + panel.className = `gc-detail ${this.detailOpen ? 'open' : ''}`; + panel.innerHTML = this.renderDetailPanel(); + this.attachDetailListeners(); + } + + // ── Node operations ── + + private addNode(type: string, x: number, y: number) { + const def = getNodeDef(type); + if (!def) return; + const id = `gn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node: GovNode = { + id, + type: def.type, + label: def.label, + position: { x, y }, + config: this.defaultConfigFor(type), + }; + this.nodes.push(node); + this.drawCanvasContent(); + this.selectNode(id); + } + + private defaultConfigFor(type: string): Record { + switch (type) { + case 'folk-gov-binary': return { assignee: '', satisfied: false }; + case 'folk-gov-threshold': return { target: 100, current: 0, unit: '', contributors: '' }; + case 'folk-gov-knob': return { min: 0, max: 100, value: 50, unit: '' }; + case 'folk-gov-project': return { description: '', gatesSatisfied: 0, gatesTotal: 0 }; + case 'folk-gov-quadratic': return { mode: 'sqrt', entries: '' }; + case 'folk-gov-conviction': return { threshold: 100, accumulated: 0, stakes: '' }; + case 'folk-gov-multisig': return { required: 2, total: 3, signers: '', signed: '' }; + case 'folk-gov-sankey': return { note: '' }; + default: return {}; + } + } + + private deleteNode(nodeId: string) { + this.nodes = this.nodes.filter(n => n.id !== nodeId); + this.edges = this.edges.filter(e => e.fromNode !== nodeId && e.toNode !== nodeId); + if (this.selectedNodeId === nodeId) { + this.selectedNodeId = null; + this.detailOpen = false; + } + this.drawCanvasContent(); + this.refreshDetailPanel(); + } + + private selectNode(nodeId: string) { + this.selectedNodeId = nodeId; + this.detailOpen = true; + const nodeLayer = this.shadow.getElementById('node-layer'); + if (nodeLayer) { + nodeLayer.querySelectorAll('.gc-node').forEach(g => { + g.classList.toggle('selected', g.getAttribute('data-node-id') === nodeId); + }); + } + this.refreshDetailPanel(); + // Re-render nodes to update border highlight + this.drawCanvasContent(); + } + + // ── Wiring ── + + private enterWiring(nodeId: string, portName: string, dir: 'input' | 'output') { + if (dir !== 'output') return; + this.wiringActive = true; + this.wiringSourceNodeId = nodeId; + this.wiringSourcePortName = portName; + const canvas = this.shadow.getElementById('gc-canvas'); + if (canvas) canvas.classList.add('wiring'); + } + + private cancelWiring() { + this.wiringActive = false; + this.wiringSourceNodeId = null; + this.wiringSourcePortName = null; + const canvas = this.shadow.getElementById('gc-canvas'); + if (canvas) canvas.classList.remove('wiring'); + const wireLayer = this.shadow.getElementById('wire-layer'); + if (wireLayer) wireLayer.innerHTML = ''; + } + + private completeWiring(targetNodeId: string, targetPortName: string, targetDir: 'input' | 'output') { + if (!this.wiringSourceNodeId || !this.wiringSourcePortName) { this.cancelWiring(); return; } + if (targetDir !== 'input') { this.cancelWiring(); return; } + if (targetNodeId === this.wiringSourceNodeId) { this.cancelWiring(); return; } + + const exists = this.edges.some(e => + e.fromNode === this.wiringSourceNodeId && e.fromPort === this.wiringSourcePortName && + e.toNode === targetNodeId && e.toPort === targetPortName + ); + if (exists) { this.cancelWiring(); return; } + + const edgeId = `ge-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + this.edges.push({ + id: edgeId, + fromNode: this.wiringSourceNodeId, + fromPort: this.wiringSourcePortName, + toNode: targetNodeId, + toPort: targetPortName, + }); + + this.cancelWiring(); + this.drawCanvasContent(); + } + + private updateWiringTempLine() { + const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null; + const wireLayer = this.shadow.getElementById('wire-layer'); + if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortName) return; + + const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId); + if (!sourceNode) return; + + const x1 = getPortX(sourceNode, this.wiringSourcePortName!, 'output'); + const y1 = getPortY(sourceNode, this.wiringSourcePortName!, 'output'); + + const rect = svg.getBoundingClientRect(); + const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom; + const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom; + + const d = bezierPath(x1, y1, x2, y2); + wireLayer.innerHTML = ``; + } + + // ── Event listeners ── + + private attachEventListeners() { + const canvas = this.shadow.getElementById('gc-canvas')!; + const svg = this.shadow.getElementById('gc-svg')!; + const palette = this.shadow.getElementById('palette')!; + + // Toolbar buttons + this.shadow.getElementById('btn-zoom-in')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 1.2); + }); + this.shadow.getElementById('btn-zoom-out')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 0.8); + }); + this.shadow.getElementById('btn-fit')?.addEventListener('click', () => this.fitView()); + this.shadow.getElementById('btn-grid')?.addEventListener('click', () => { + this.showGrid = !this.showGrid; + this.render(); + requestAnimationFrame(() => this.fitView()); + }); + + // Bottom zoom controls + this.shadow.getElementById('zoom-in')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 1.2); + }); + this.shadow.getElementById('zoom-out')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 0.8); + }); + this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); + + // Canvas wheel zoom/pan + canvas.addEventListener('wheel', (e: WheelEvent) => { + e.preventDefault(); + if (e.ctrlKey || e.metaKey) { + const rect = svg.getBoundingClientRect(); + const factor = e.deltaY < 0 ? 1.1 : 0.9; + this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor); + } else { + this.canvasPanX -= e.deltaX; + this.canvasPanY -= e.deltaY; + this.updateCanvasTransform(); + } + }, { passive: false }); + + // Palette drag + palette.querySelectorAll('.gc-palette__card').forEach(card => { + card.addEventListener('dragstart', (e: Event) => { + const de = e as DragEvent; + const type = (card as HTMLElement).dataset.nodeType!; + de.dataTransfer?.setData('text/plain', type); + }); + }); + + // Canvas drop + canvas.addEventListener('dragover', (e: DragEvent) => { e.preventDefault(); }); + canvas.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + const type = e.dataTransfer?.getData('text/plain'); + if (!type) return; + const rect = svg.getBoundingClientRect(); + const x = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom; + const y = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom; + this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2); + }); + + // SVG pointer events + svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e)); + + // Global move/up/key + this._boundPointerMove = (e: PointerEvent) => this.handlePointerMove(e); + this._boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e); + this._boundKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e); + document.addEventListener('pointermove', this._boundPointerMove); + document.addEventListener('pointerup', this._boundPointerUp); + document.addEventListener('keydown', this._boundKeyDown); + + // Detail panel + this.attachDetailListeners(); + } + + private attachDetailListeners() { + this.shadow.getElementById('detail-close')?.addEventListener('click', () => { + this.detailOpen = false; + this.selectedNodeId = null; + const panel = this.shadow.getElementById('detail-panel'); + if (panel) panel.className = 'gc-detail'; + this.drawCanvasContent(); + }); + + this.shadow.getElementById('detail-label')?.addEventListener('input', (e) => { + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (node) { + node.label = (e.target as HTMLInputElement).value; + this.drawCanvasContent(); + } + }); + + this.shadow.getElementById('detail-delete-node')?.addEventListener('click', () => { + if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); + }); + + const panel = this.shadow.getElementById('detail-panel'); + if (panel) { + panel.querySelectorAll('[data-config-key]').forEach(el => { + const handler = (e: Event) => { + const key = (el as HTMLElement).dataset.configKey!; + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (!node) return; + let val: any = (e.target as HTMLInputElement).value; + // Type coerce numbers + if ((e.target as HTMLInputElement).type === 'number') { + val = parseFloat(val) || 0; + } + // Coerce booleans from select + if (val === 'true') val = true; + if (val === 'false') val = false; + node.config[key] = val; + this.drawCanvasContent(); + }; + el.addEventListener('input', handler); + el.addEventListener('change', handler); + }); + } + } + + private handlePointerDown(e: PointerEvent) { + const target = e.target as Element; + + // Port click + const portGroup = target.closest('.gc-port-group') as SVGElement | null; + if (portGroup) { + e.stopPropagation(); + const nodeId = portGroup.dataset.nodeId!; + const portName = portGroup.dataset.portName!; + const dir = portGroup.dataset.portDir as 'input' | 'output'; + + if (this.wiringActive) { + this.completeWiring(nodeId, portName, dir); + } else { + this.enterWiring(nodeId, portName, dir); + } + return; + } + + // Edge click — delete + const edgeGroup = target.closest('.gc-edge-group') as SVGElement | null; + if (edgeGroup) { + e.stopPropagation(); + const edgeId = edgeGroup.dataset.edgeId!; + this.edges = this.edges.filter(ed => ed.id !== edgeId); + this.redrawEdges(); + return; + } + + // Node click — select + drag + const nodeGroup = target.closest('.gc-node') as SVGElement | null; + if (nodeGroup) { + e.stopPropagation(); + if (this.wiringActive) { + this.cancelWiring(); + return; + } + const nodeId = nodeGroup.dataset.nodeId!; + this.selectNode(nodeId); + + const node = this.nodes.find(n => n.id === nodeId); + if (node) { + this.draggingNodeId = nodeId; + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.dragNodeStartX = node.position.x; + this.dragNodeStartY = node.position.y; + } + return; + } + + // Canvas — pan or deselect + if (this.wiringActive) { + this.cancelWiring(); + return; + } + + this.isPanning = true; + this.panStartX = e.clientX; + this.panStartY = e.clientY; + this.panStartPanX = this.canvasPanX; + this.panStartPanY = this.canvasPanY; + const canvas = this.shadow.getElementById('gc-canvas'); + if (canvas) canvas.classList.add('grabbing'); + + if (this.selectedNodeId) { + this.selectedNodeId = null; + this.detailOpen = false; + this.drawCanvasContent(); + this.refreshDetailPanel(); + } + } + + private handlePointerMove(e: PointerEvent) { + if (this.wiringActive) { + this.wiringPointerX = e.clientX; + this.wiringPointerY = e.clientY; + this.updateWiringTempLine(); + return; + } + + if (this.draggingNodeId) { + const node = this.nodes.find(n => n.id === this.draggingNodeId); + if (node) { + const dx = (e.clientX - this.dragStartX) / this.canvasZoom; + const dy = (e.clientY - this.dragStartY) / this.canvasZoom; + node.position.x = this.dragNodeStartX + dx; + node.position.y = this.dragNodeStartY + dy; + this.updateNodePosition(node); + this.redrawEdges(); + } + return; + } + + if (this.isPanning) { + this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); + this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); + this.updateCanvasTransform(); + } + } + + private handlePointerUp(_e: PointerEvent) { + if (this.draggingNodeId) { + this.draggingNodeId = null; + } + if (this.isPanning) { + this.isPanning = false; + const canvas = this.shadow.getElementById('gc-canvas'); + if (canvas) canvas.classList.remove('grabbing'); + } + } + + private handleKeyDown(e: KeyboardEvent) { + const tag = (e.target as Element)?.tagName; + const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + + if (e.key === 'Escape') { + if (this.wiringActive) this.cancelWiring(); + } + if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedNodeId) { + if (isEditing) return; + this.deleteNode(this.selectedNodeId); + } + if (isEditing) return; + if (e.key === 'f' || e.key === 'F') { + this.fitView(); + } + if (e.key === '=' || e.key === '+') { + const svg = this.shadow.getElementById('gc-svg'); + if (svg) { const r = svg.getBoundingClientRect(); this.zoomAt(r.width / 2, r.height / 2, 1.2); } + } + if (e.key === '-') { + const svg = this.shadow.getElementById('gc-svg'); + if (svg) { const r = svg.getBoundingClientRect(); this.zoomAt(r.width / 2, r.height / 2, 0.8); } + } + } +} + +customElements.define('folk-gov-circuit', FolkGovCircuit); diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts index 47bdd51..1f8491a 100644 --- a/modules/rgov/mod.ts +++ b/modules/rgov/mod.ts @@ -8,7 +8,6 @@ */ import { Hono } from "hono"; -import { resolve } from "path"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -17,53 +16,10 @@ import { addShapes, getDocumentData } from "../../server/community-store"; const routes = new Hono(); -// ── Canvas content loader (same approach as rspace module) ── +// ── Module page — renders standalone GovMod circuit canvas ── -const DIST_DIR = resolve(import.meta.dir, "../../dist"); -let canvasCache: { body: string; styles: string; scripts: string } | null = null; - -function extractCanvasContent(html: string) { - const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); - const styleMatches = [...html.matchAll(/]*>([\s\S]*?)<\/style>/gi)]; - const scriptMatches = [...html.matchAll(/]*>[\s\S]*?<\/script>/gi)]; - return { - body: bodyMatch?.[1] || "", - styles: styleMatches.map(m => m[0]).join("\n"), - scripts: scriptMatches.map(m => m[0]).join("\n"), - }; -} - -async function getCanvasContent() { - if (canvasCache) return canvasCache; - - const moduleFile = Bun.file(resolve(DIST_DIR, "canvas-module.html")); - if (await moduleFile.exists()) { - canvasCache = { - body: await moduleFile.text(), - styles: "", - scripts: ``, - }; - return canvasCache; - } - - const fullFile = Bun.file(resolve(DIST_DIR, "canvas.html")); - if (await fullFile.exists()) { - canvasCache = extractCanvasContent(await fullFile.text()); - return canvasCache; - } - - return { - body: `
Canvas loading...
`, - styles: "", - scripts: "", - }; -} - -// ── Module page (within a space) — renders canvas directly ── - -routes.get("/", async (c) => { +routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - const canvas = await getCanvasContent(); return c.html(renderShell({ title: `${space} — rGov | rSpace`, @@ -71,9 +27,8 @@ routes.get("/", async (c) => { spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - body: canvas.body, - styles: canvas.styles, - scripts: canvas.scripts, + body: ``, + scripts: ``, })); }); diff --git a/vite.config.ts b/vite.config.ts index 97d5608..9c7d58e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1394,6 +1394,27 @@ export default defineConfig({ } } + // ── Build rGov circuit canvas component ── + mkdirSync(resolve(__dirname, "dist/modules/rgov"), { recursive: true }); + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rgov/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rgov"), + lib: { + entry: resolve(__dirname, "modules/rgov/components/folk-gov-circuit.ts"), + formats: ["es"], + fileName: () => "folk-gov-circuit.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-gov-circuit.js", + }, + }, + }, + }); + // ── Generate content hashes for cache-busting ── const { readdirSync, readFileSync, writeFileSync, statSync: statSync2 } = await import("node:fs"); const { createHash } = await import("node:crypto"); From ba7a5733b8b30c0e9f02ae78f1e144588956ab3e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 02:04:52 +0000 Subject: [PATCH 4/8] fix(rgov): mobile-friendly canvas with collapsible sidebars and touch support - Collapsible palette sidebar (hamburger toggle, hidden by default on mobile) - Pinch-to-zoom and two-finger pan for touch/pen - Larger touch targets for ports and zoom controls - Responsive text sizing and compact toolbar on small screens - Detail panel goes full-width on very small screens - touch-action: none on SVG to prevent browser gesture conflicts Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/rgov/components/folk-gov-circuit.ts | 234 +++++++++++++++++++- 1 file changed, 232 insertions(+), 2 deletions(-) diff --git a/modules/rgov/components/folk-gov-circuit.ts b/modules/rgov/components/folk-gov-circuit.ts index 924c185..b8bae48 100644 --- a/modules/rgov/components/folk-gov-circuit.ts +++ b/modules/rgov/components/folk-gov-circuit.ts @@ -675,6 +675,136 @@ const STYLES = ` .gc-detail__delete:hover { background: #7f1d1d; } + +/* ── Collapsible sidebar toggle buttons ── */ + +.gc-sidebar-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid #334155; + border-radius: 6px; + background: #1e293b; + color: #94a3b8; + font-size: 16px; + cursor: pointer; + flex-shrink: 0; +} + +.gc-sidebar-toggle:hover { + background: #334155; + color: #e2e8f0; +} + +.gc-sidebar-toggle.active { + background: #334155; + color: #38bdf8; + border-color: #38bdf8; +} + +/* ── Palette collapsed state ── */ + +.gc-palette { + transition: width 0.2s, padding 0.2s, opacity 0.15s; +} + +.gc-palette.collapsed { + width: 0; + padding: 0; + overflow: hidden; + border-right: none; + opacity: 0; + pointer-events: none; +} + +/* ── Touch & pen support ── */ + +.gc-canvas svg { + touch-action: none; + -webkit-user-select: none; + user-select: none; +} + +/* ── Mobile responsive ── */ + +@media (max-width: 768px) { + .gc-toolbar { + padding: 6px 10px; + gap: 6px; + } + .gc-toolbar__title { + font-size: 13px; + gap: 6px; + } + .gc-btn { + padding: 4px 8px; + font-size: 11px; + } + .gc-palette { + width: 0; + padding: 0; + overflow: hidden; + border-right: none; + opacity: 0; + pointer-events: none; + } + .gc-palette.mobile-open { + width: 160px; + padding: 10px 6px; + overflow-y: auto; + border-right: 1px solid #334155; + opacity: 1; + pointer-events: auto; + position: absolute; + left: 0; + top: 0; + bottom: 0; + z-index: 10; + background: #1e293b; + } + .gc-detail.open { + width: 240px; + position: absolute; + right: 0; + top: 0; + bottom: 0; + z-index: 10; + background: #1e293b; + } + .gc-zoom-controls { + bottom: 10px; + } + .gc-zoom-btn { + width: 36px; + height: 36px; + } + .gc-zoom-level { + font-size: 12px; + min-width: 44px; + } + /* Larger port hit targets for touch */ + .gc-port-hit { + r: 18; + } + .gc-palette__card { + padding: 8px 8px; + font-size: 13px; + } +} + +@media (max-width: 480px) { + .gc-toolbar__title span:not(:first-child) { + display: none; + } + .gc-detail.open { + width: 100%; + } + .gc-palette.mobile-open { + width: 180px; + } +} `; // ── Component ── @@ -692,6 +822,10 @@ export class FolkGovCircuit extends HTMLElement { private canvasPanY = 0; private showGrid = true; + // Sidebar state + private paletteOpen = false; + private detailOpen = false; + // Interaction private isPanning = false; private panStartX = 0; @@ -704,9 +838,15 @@ export class FolkGovCircuit extends HTMLElement { private dragNodeStartX = 0; private dragNodeStartY = 0; + // Touch — pinch-to-zoom + private activeTouches: Map = new Map(); + private pinchStartDist = 0; + private pinchStartZoom = 1; + private pinchMidX = 0; + private pinchMidY = 0; + // Selection & detail panel private selectedNodeId: string | null = null; - private detailOpen = false; // Wiring private wiringActive = false; @@ -719,6 +859,9 @@ export class FolkGovCircuit extends HTMLElement { private _boundPointerMove: ((e: PointerEvent) => void) | null = null; private _boundPointerUp: ((e: PointerEvent) => void) | null = null; private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; + private _boundTouchStart: ((e: TouchEvent) => void) | null = null; + private _boundTouchMove: ((e: TouchEvent) => void) | null = null; + private _boundTouchEnd: ((e: TouchEvent) => void) | null = null; constructor() { super(); @@ -735,6 +878,9 @@ export class FolkGovCircuit extends HTMLElement { if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); + if (this._boundTouchStart) document.removeEventListener('touchstart', this._boundTouchStart); + if (this._boundTouchMove) document.removeEventListener('touchmove', this._boundTouchMove); + if (this._boundTouchEnd) document.removeEventListener('touchend', this._boundTouchEnd); } // ── Data init ── @@ -811,6 +957,7 @@ export class FolkGovCircuit extends HTMLElement {
+ \u25A3 Governance Circuits
@@ -822,7 +969,7 @@ export class FolkGovCircuit extends HTMLElement {
-
+
Node Types
${GOV_NODE_CATALOG.map(n => `
@@ -1354,6 +1501,18 @@ export class FolkGovCircuit extends HTMLElement { this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2); }); + // Palette toggle + this.shadow.getElementById('btn-palette-toggle')?.addEventListener('click', () => { + this.paletteOpen = !this.paletteOpen; + const pal = this.shadow.getElementById('palette'); + const btn = this.shadow.getElementById('btn-palette-toggle'); + if (pal) { + pal.classList.toggle('collapsed', !this.paletteOpen); + pal.classList.toggle('mobile-open', this.paletteOpen); + } + if (btn) btn.classList.toggle('active', this.paletteOpen); + }); + // SVG pointer events svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e)); @@ -1365,6 +1524,14 @@ export class FolkGovCircuit extends HTMLElement { document.addEventListener('pointerup', this._boundPointerUp); document.addEventListener('keydown', this._boundKeyDown); + // Touch: pinch-to-zoom + two-finger pan + this._boundTouchStart = (e: TouchEvent) => this.handleTouchStart(e); + this._boundTouchMove = (e: TouchEvent) => this.handleTouchMove(e); + this._boundTouchEnd = (e: TouchEvent) => this.handleTouchEnd(e); + svg.addEventListener('touchstart', this._boundTouchStart, { passive: false }); + svg.addEventListener('touchmove', this._boundTouchMove, { passive: false }); + svg.addEventListener('touchend', this._boundTouchEnd, { passive: false }); + // Detail panel this.attachDetailListeners(); } @@ -1526,6 +1693,69 @@ export class FolkGovCircuit extends HTMLElement { } } + // ── Touch handlers (pinch-to-zoom, two-finger pan) ── + + private handleTouchStart(e: TouchEvent) { + for (let i = 0; i < e.changedTouches.length; i++) { + const t = e.changedTouches[i]; + this.activeTouches.set(t.identifier, { x: t.clientX, y: t.clientY }); + } + if (this.activeTouches.size === 2) { + e.preventDefault(); + const pts = [...this.activeTouches.values()]; + this.pinchStartDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); + this.pinchStartZoom = this.canvasZoom; + this.pinchMidX = (pts[0].x + pts[1].x) / 2; + this.pinchMidY = (pts[0].y + pts[1].y) / 2; + this.panStartPanX = this.canvasPanX; + this.panStartPanY = this.canvasPanY; + } + } + + private handleTouchMove(e: TouchEvent) { + for (let i = 0; i < e.changedTouches.length; i++) { + const t = e.changedTouches[i]; + this.activeTouches.set(t.identifier, { x: t.clientX, y: t.clientY }); + } + if (this.activeTouches.size === 2) { + e.preventDefault(); + const pts = [...this.activeTouches.values()]; + const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); + const midX = (pts[0].x + pts[1].x) / 2; + const midY = (pts[0].y + pts[1].y) / 2; + + // Zoom + const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null; + if (svg) { + const rect = svg.getBoundingClientRect(); + const scale = dist / this.pinchStartDist; + const newZoom = Math.max(0.1, Math.min(4, this.pinchStartZoom * scale)); + const cx = this.pinchMidX - rect.left; + const cy = this.pinchMidY - rect.top; + this.canvasPanX = cx - (cx - this.panStartPanX) * (newZoom / this.pinchStartZoom); + this.canvasPanY = cy - (cy - this.panStartPanY) * (newZoom / this.pinchStartZoom); + this.canvasZoom = newZoom; + + // Pan offset from midpoint movement + this.canvasPanX += (midX - this.pinchMidX); + this.canvasPanY += (midY - this.pinchMidY); + this.pinchMidX = midX; + this.pinchMidY = midY; + + this.updateCanvasTransform(); + } + } + } + + private handleTouchEnd(e: TouchEvent) { + for (let i = 0; i < e.changedTouches.length; i++) { + this.activeTouches.delete(e.changedTouches[i].identifier); + } + if (this.activeTouches.size < 2) { + this.pinchStartDist = 0; + } + } + private handleKeyDown(e: KeyboardEvent) { const tag = (e.target as Element)?.tagName; const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; From df8901c9756b112fe29ee830a31f6d73faffdf21 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 02:07:13 +0000 Subject: [PATCH 5/8] feat(rgov): clickable signoff gates with authority check + project recalc Binary signoff nodes now have a clickable checkbox that toggles satisfied state. Checks EncryptID JWT for authority (assignee match), falls back to allowing anyone in demo mode. Toggling a signoff auto-recalculates connected project aggregator gate counts. Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/rgov/components/folk-gov-circuit.ts | 119 ++++++++++++++++++-- 1 file changed, 111 insertions(+), 8 deletions(-) diff --git a/modules/rgov/components/folk-gov-circuit.ts b/modules/rgov/components/folk-gov-circuit.ts index b8bae48..e985bc1 100644 --- a/modules/rgov/components/folk-gov-circuit.ts +++ b/modules/rgov/components/folk-gov-circuit.ts @@ -164,7 +164,7 @@ function buildDemoData(): { nodes: GovNode[]; edges: GovEdge[] } { nodes.push({ id: 'c2-venue', type: 'folk-gov-binary', label: 'Venue Signoff', position: { x: 50, y: c2y + 320 }, - config: { assignee: 'Carlos', satisfied: true }, + config: { assignee: 'Carlos', satisfied: true, signedBy: 'Carlos' }, }); nodes.push({ id: 'c2-project', type: 'folk-gov-project', label: 'Community Potluck', @@ -247,14 +247,28 @@ function renderNodeBody(node: GovNode): string { switch (node.type) { case 'folk-gov-binary': { const satisfied = c.satisfied ? 'Yes' : 'No'; - const icon = c.satisfied - ? '' - : ''; + const checkColor = c.satisfied ? '#22c55e' : '#475569'; + const checkBg = c.satisfied ? 'rgba(34,197,94,0.15)' : 'rgba(71,85,105,0.15)'; + const checkIcon = c.satisfied ? '✔' : ''; return `
Assignee: ${esc(c.assignee || 'Unassigned')}
-
- ${icon} - ${satisfied} +
+
${checkIcon}
+ ${satisfied} + ${c.satisfied && c.signedBy ? `by ${esc(c.signedBy)}` : ''}
`; } case 'folk-gov-threshold': { @@ -889,6 +903,7 @@ export class FolkGovCircuit extends HTMLElement { const demo = buildDemoData(); this.nodes = demo.nodes; this.edges = demo.edges; + this.recalcProjectGates(); this.render(); requestAnimationFrame(() => this.fitView()); } @@ -1268,6 +1283,94 @@ export class FolkGovCircuit extends HTMLElement { edgeLayer.innerHTML = this.renderAllEdges(); nodeLayer.innerHTML = this.renderAllNodes(); if (wireLayer) wireLayer.innerHTML = ''; + this.attachSignoffHandlers(); + } + + private attachSignoffHandlers() { + const nodeLayer = this.shadow.getElementById('node-layer'); + if (!nodeLayer) return; + nodeLayer.querySelectorAll('.gc-signoff-toggle').forEach(el => { + el.addEventListener('click', (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + const nodeId = (el as HTMLElement).dataset.nodeId; + if (!nodeId) return; + this.toggleSignoff(nodeId); + }); + // Prevent the click from starting a node drag + el.addEventListener('pointerdown', (e: Event) => { + e.stopPropagation(); + }); + }); + } + + private toggleSignoff(nodeId: string) { + const node = this.nodes.find(n => n.id === nodeId); + if (!node || node.type !== 'folk-gov-binary') return; + + // Authority check: resolve current user from EncryptID JWT if available + let currentUser = 'You'; + try { + const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('auth=')); + if (token) { + const payload = JSON.parse(atob(token.split('=')[1].split('.')[1])); + currentUser = payload.username || payload.sub || 'You'; + } + } catch { /* no auth context — allow toggle for demo */ } + + // Check authority: if assignee is set, only the assignee (or admin) can toggle + const assignee = (node.config.assignee || '').toLowerCase().trim(); + const userLower = currentUser.toLowerCase().trim(); + if (assignee && assignee !== 'unassigned' && !assignee.includes('pending')) { + // Extract just the name part (handle "Carlos", "Landlord (pending)", etc.) + const assigneeName = assignee.replace(/\s*\(.*\)\s*$/, ''); + if (assigneeName && assigneeName !== userLower && userLower !== 'you') { + // Not authorized — show brief feedback + const el = this.shadow.querySelector(`[data-node-id="${nodeId}"].gc-signoff-toggle`) as HTMLElement; + if (el) { + el.style.outline = '2px solid #ef4444'; + el.style.outlineOffset = '2px'; + setTimeout(() => { el.style.outline = ''; el.style.outlineOffset = ''; }, 600); + } + return; + } + } + + // Toggle + node.config.satisfied = !node.config.satisfied; + node.config.signedBy = node.config.satisfied ? currentUser : ''; + + // Update connected project aggregators + this.recalcProjectGates(); + + // Re-render + this.drawCanvasContent(); + if (this.selectedNodeId === nodeId) { + this.refreshDetailPanel(); + } + } + + private recalcProjectGates() { + // For each project node, count how many of its incoming edges come from satisfied gates + for (const node of this.nodes) { + if (node.type !== 'folk-gov-project') continue; + const incomingEdges = this.edges.filter(e => e.toNode === node.id); + let satisfied = 0; + let total = incomingEdges.length; + for (const edge of incomingEdges) { + const source = this.nodes.find(n => n.id === edge.fromNode); + if (!source) continue; + if (source.type === 'folk-gov-binary' && source.config.satisfied) satisfied++; + else if (source.type === 'folk-gov-threshold' && source.config.current >= source.config.target) satisfied++; + else if (source.type === 'folk-gov-multisig') { + const signed = (source.config.signed || '').split(',').filter((s: string) => s.trim()).length; + if (signed >= source.config.required) satisfied++; + } + else if (source.type === 'folk-gov-conviction' && source.config.accumulated >= source.config.threshold) satisfied++; + } + node.config.gatesSatisfied = satisfied; + node.config.gatesTotal = total; + } } private redrawEdges() { @@ -1331,7 +1434,7 @@ export class FolkGovCircuit extends HTMLElement { private defaultConfigFor(type: string): Record { switch (type) { - case 'folk-gov-binary': return { assignee: '', satisfied: false }; + case 'folk-gov-binary': return { assignee: '', satisfied: false, signedBy: '' }; case 'folk-gov-threshold': return { target: 100, current: 0, unit: '', contributors: '' }; case 'folk-gov-knob': return { min: 0, max: 100, value: 50, unit: '' }; case 'folk-gov-project': return { description: '', gatesSatisfied: 0, gatesTotal: 0 }; From 7f98dfcdb1817a3834c5770cd34053c3c73d19b6 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 04:05:51 +0000 Subject: [PATCH 6/8] fix: load persisted docs before module onInit to prevent re-seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module onInit functions (rvote, rtasks, rcal, etc.) call seedDemoIfEmpty which checks the sync server for existing docs. Previously onInit ran as an IIFE before loadAllDocs completed, so it always found empty docs and re-seeded demo data — overwriting user deletions/changes. Now onInit runs inside the loadAllDocs .then() chain, ensuring persisted data is loaded before any seed checks run. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/index.ts | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/server/index.ts b/server/index.ts index 32deafa..c5b53e3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3992,33 +3992,35 @@ const server = Bun.serve({ // ── Startup ── -// Call onInit for each module that defines it (schema registration, DB init, etc.) -(async () => { - for (const mod of getAllModules()) { - if (mod.onInit) { - try { - await mod.onInit({ syncServer }); - console.log(`[Init] ${mod.name} initialized`); - } catch (e) { - console.error(`[Init] ${mod.name} failed:`, e); - } - } - } - // Pass syncServer to OAuth handlers - setNotionOAuthSyncServer(syncServer); - setGoogleOAuthSyncServer(syncServer); - setClickUpOAuthSyncServer(syncServer); - setOAuthStatusSyncServer(syncServer); -})(); - // Ensure generated files directory exists import { mkdirSync } from "node:fs"; try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), { recursive: true }); } catch {} -ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e)); import { initTokenService, seedCUSDC } from "./token-service"; + +// IMPORTANT: Load persisted docs FIRST, then run module onInit + seeding. +// Previously onInit ran before loadAllDocs, causing seed functions to see +// empty docs and re-create demo data that users had already deleted. +ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e)); loadAllDocs(syncServer) .then(async () => { + // Now that persisted docs are loaded, init modules (which may seed) + for (const mod of getAllModules()) { + if (mod.onInit) { + try { + await mod.onInit({ syncServer }); + console.log(`[Init] ${mod.name} initialized`); + } catch (e) { + console.error(`[Init] ${mod.name} failed:`, e); + } + } + } + // Pass syncServer to OAuth handlers + setNotionOAuthSyncServer(syncServer); + setGoogleOAuthSyncServer(syncServer); + setClickUpOAuthSyncServer(syncServer); + setOAuthStatusSyncServer(syncServer); + // Seed all modules' demo data so /demo routes always have content for (const mod of getAllModules()) { if (mod.seedTemplate) { From bf8e11d426ce974bf0d58d82822b31aed1604c95 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 18:32:02 -0400 Subject: [PATCH 7/8] feat(rexchange): add P2P crypto/fiat exchange module with escrow & reputation 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 --- lib/canvas-tools.ts | 21 + lib/folk-exchange-node.ts | 372 ++++++++++++++ lib/index.ts | 3 + modules/rexchange/exchange-rates.ts | 122 +++++ modules/rexchange/exchange-reputation.ts | 124 +++++ modules/rexchange/exchange-routes.ts | 617 +++++++++++++++++++++++ modules/rexchange/exchange-settlement.ts | 390 ++++++++++++++ modules/rexchange/exchange-solver.ts | 221 ++++++++ modules/rexchange/landing.ts | 239 +++++++++ modules/rexchange/mod.ts | 86 ++++ modules/rexchange/schemas.ts | 205 ++++++++ server/index.ts | 2 + 12 files changed, 2402 insertions(+) create mode 100644 lib/folk-exchange-node.ts create mode 100644 modules/rexchange/exchange-rates.ts create mode 100644 modules/rexchange/exchange-reputation.ts create mode 100644 modules/rexchange/exchange-routes.ts create mode 100644 modules/rexchange/exchange-settlement.ts create mode 100644 modules/rexchange/exchange-solver.ts create mode 100644 modules/rexchange/landing.ts create mode 100644 modules/rexchange/mod.ts create mode 100644 modules/rexchange/schemas.ts diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index 29f0e87..b15282e 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -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: { diff --git a/lib/folk-exchange-node.ts b/lib/folk-exchange-node.ts new file mode 100644 index 0000000..490b148 --- /dev/null +++ b/lib/folk-exchange-node.ts @@ -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 = { + 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 = ` + 💱 rExchange + 0 intents + `; + 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 = ` + + Buy + + + Sell + + + Matched + + `; + 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); +} diff --git a/lib/index.ts b/lib/index.ts index 8aa4d07..5d55798 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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"; diff --git a/modules/rexchange/exchange-rates.ts b/modules/rexchange/exchange-rates.ts new file mode 100644 index 0000000..9bf6ad2 --- /dev/null +++ b/modules/rexchange/exchange-rates.ts @@ -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; // fiat currency → USD/fiat rate + ts: number; +} + +const TTL = 5 * 60 * 1000; +let cached: RateEntry | null = null; +let inFlight: Promise | 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 = { + EUR: 'eur', USD: 'usd', GBP: 'gbp', BRL: 'brl', + MXN: 'mxn', INR: 'inr', NGN: 'ngn', ARS: 'ars', +}; + +async function fetchRates(): Promise { + if (cached && Date.now() - cached.ts < TTL) return cached; + if (inFlight) return inFlight; + + inFlight = (async (): Promise => { + 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 { + 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> { + const result: Record = {}; + 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; +} diff --git a/modules/rexchange/exchange-reputation.ts b/modules/rexchange/exchange-reputation.ts new file mode 100644 index 0000000..da12c40 --- /dev/null +++ b/modules/rexchange/exchange-reputation.ts @@ -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 }, +): boolean { + return Object.values(intentsDoc.intents).some( + i => i.creatorDid === did && i.isStandingOrder && i.status === 'active', + ); +} diff --git a/modules/rexchange/exchange-routes.ts b/modules/rexchange/exchange-routes.ts new file mode 100644 index 0000000..c157d2a --- /dev/null +++ b/modules/rexchange/exchange-routes.ts @@ -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(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init exchange intents', (d) => { + const init = exchangeIntentsSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + syncServer.setDoc(docId, doc); + } + return doc; + } + + function ensureTradesDoc(space: string): ExchangeTradesDoc { + const syncServer = ss(); + const docId = exchangeTradesDocId(space); + let doc = syncServer.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init exchange trades', (d) => { + const init = exchangeTradesSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + syncServer.setDoc(docId, doc); + } + return doc; + } + + function ensureReputationDoc(space: string): ExchangeReputationDoc { + const syncServer = ss(); + const docId = exchangeReputationDocId(space); + let doc = syncServer.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init exchange reputation', (d) => { + const init = exchangeReputationSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + syncServer.setDoc(docId, doc); + } + return doc; + } + + // ── POST /api/exchange/intent — Create buy/sell intent ── + + routes.post('/api/exchange/intent', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const body = await c.req.json(); + + const { side, tokenId, fiatCurrency, tokenAmountMin, tokenAmountMax, rateType } = body; + + // Validation + if (!VALID_SIDES.includes(side)) return c.json({ error: `side must be: ${VALID_SIDES.join(', ')}` }, 400); + if (!VALID_TOKENS.includes(tokenId)) return c.json({ error: `tokenId must be: ${VALID_TOKENS.join(', ')}` }, 400); + if (!VALID_FIATS.includes(fiatCurrency)) return c.json({ error: `fiatCurrency must be: ${VALID_FIATS.join(', ')}` }, 400); + if (!VALID_RATE_TYPES.includes(rateType)) return c.json({ error: `rateType must be: ${VALID_RATE_TYPES.join(', ')}` }, 400); + if (typeof tokenAmountMin !== 'number' || typeof tokenAmountMax !== 'number' || tokenAmountMin <= 0 || tokenAmountMax < tokenAmountMin) { + return c.json({ error: 'tokenAmountMin/Max must be positive numbers, max >= min' }, 400); + } + if (rateType === 'fixed' && (body.rateFixed == null || typeof body.rateFixed !== 'number')) { + return c.json({ error: 'rateFixed required for fixed rate type' }, 400); + } + if (rateType === 'market_plus_bps' && (body.rateMarketBps == null || typeof body.rateMarketBps !== 'number')) { + return c.json({ error: 'rateMarketBps required for market_plus_bps rate type' }, 400); + } + if (!body.paymentMethods?.length) { + return c.json({ error: 'At least one payment method required' }, 400); + } + + const id = crypto.randomUUID(); + const now = Date.now(); + ensureIntentsDoc(space); + + const intent: ExchangeIntent = { + id, + creatorDid: claims.did as string, + creatorName: claims.username as string || 'Unknown', + side, + tokenId, + fiatCurrency, + tokenAmountMin, + tokenAmountMax, + rateType, + rateFixed: body.rateFixed, + rateMarketBps: body.rateMarketBps, + paymentMethods: body.paymentMethods, + isStandingOrder: body.isStandingOrder || false, + autoAccept: body.autoAccept || false, + allowInstitutionalFallback: body.allowInstitutionalFallback || false, + minCounterpartyReputation: body.minCounterpartyReputation, + preferredCounterparties: body.preferredCounterparties, + status: 'active', + createdAt: now, + expiresAt: body.expiresAt, + }; + + ss().changeDoc(exchangeIntentsDocId(space), 'create exchange intent', (d) => { + d.intents[id] = intent as any; + }); + + const doc = ss().getDoc(exchangeIntentsDocId(space))!; + return c.json(doc.intents[id], 201); + }); + + // ── PATCH /api/exchange/intent/:id — Update/cancel intent ── + + routes.patch('/api/exchange/intent/:id', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + const body = await c.req.json(); + + ensureIntentsDoc(space); + const doc = ss().getDoc(exchangeIntentsDocId(space))!; + const intent = doc.intents[id]; + if (!intent) return c.json({ error: 'Intent not found' }, 404); + if (intent.creatorDid !== claims.did) return c.json({ error: 'Not your intent' }, 403); + + ss().changeDoc(exchangeIntentsDocId(space), 'update exchange intent', (d) => { + const i = d.intents[id]; + if (body.status === 'cancelled') i.status = 'cancelled' as any; + if (body.tokenAmountMin != null) i.tokenAmountMin = body.tokenAmountMin; + if (body.tokenAmountMax != null) i.tokenAmountMax = body.tokenAmountMax; + if (body.rateFixed != null) i.rateFixed = body.rateFixed as any; + if (body.rateMarketBps != null) i.rateMarketBps = body.rateMarketBps as any; + if (body.paymentMethods) i.paymentMethods = body.paymentMethods as any; + if (body.minCounterpartyReputation != null) i.minCounterpartyReputation = body.minCounterpartyReputation as any; + }); + + const updated = ss().getDoc(exchangeIntentsDocId(space))!; + return c.json(updated.intents[id]); + }); + + // ── GET /api/exchange/intents — Order book (active) ── + + routes.get('/api/exchange/intents', (c) => { + const space = c.req.param('space') || 'demo'; + ensureIntentsDoc(space); + const doc = ss().getDoc(exchangeIntentsDocId(space))!; + let intents = Object.values(doc.intents).filter(i => i.status === 'active'); + + // Optional filters + const tokenId = c.req.query('tokenId'); + const fiatCurrency = c.req.query('fiatCurrency'); + const side = c.req.query('side'); + if (tokenId) intents = intents.filter(i => i.tokenId === tokenId); + if (fiatCurrency) intents = intents.filter(i => i.fiatCurrency === fiatCurrency); + if (side) intents = intents.filter(i => i.side === side); + + return c.json({ intents }); + }); + + // ── GET /api/exchange/intents/mine — My intents ── + + routes.get('/api/exchange/intents/mine', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + ensureIntentsDoc(space); + const doc = ss().getDoc(exchangeIntentsDocId(space))!; + const intents = Object.values(doc.intents).filter(i => i.creatorDid === claims.did); + return c.json({ intents }); + }); + + // ── GET /api/exchange/matches — Pending matches for me ── + + routes.get('/api/exchange/matches', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + ensureTradesDoc(space); + const doc = ss().getDoc(exchangeTradesDocId(space))!; + + const matches = Object.values(doc.trades).filter(t => + t.status === 'proposed' && + (t.buyerDid === claims.did || t.sellerDid === claims.did), + ); + + return c.json({ matches }); + }); + + // ── POST /api/exchange/matches/:id/accept — Accept match ── + + routes.post('/api/exchange/matches/:id/accept', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.status !== 'proposed') return c.json({ error: `Trade status is ${trade.status}` }, 400); + if (trade.buyerDid !== claims.did && trade.sellerDid !== claims.did) { + return c.json({ error: 'Not a party to this trade' }, 403); + } + + ss().changeDoc(exchangeTradesDocId(space), 'accept match', (d) => { + d.trades[id].acceptances[claims.did as string] = true as any; + }); + + // Check if both accepted + const updated = ss().getDoc(exchangeTradesDocId(space))!; + const updatedTrade = updated.trades[id]; + const allAccepted = updatedTrade.acceptances[updatedTrade.buyerDid] && + updatedTrade.acceptances[updatedTrade.sellerDid]; + + if (allAccepted) { + // Lock escrow + ss().changeDoc(exchangeTradesDocId(space), 'mark accepted', (d) => { + d.trades[id].status = 'accepted' as any; + }); + + // Mark intents as matched + ss().changeDoc(exchangeIntentsDocId(space), 'mark intents matched', (d) => { + if (d.intents[updatedTrade.buyIntentId]) d.intents[updatedTrade.buyIntentId].status = 'matched' as any; + if (d.intents[updatedTrade.sellIntentId]) d.intents[updatedTrade.sellIntentId].status = 'matched' as any; + }); + + const freshDoc = ss().getDoc(exchangeTradesDocId(space))!; + const lockResult = lockEscrow(freshDoc.trades[id], ss(), space); + if (!lockResult.success) { + // Revert + ss().changeDoc(exchangeTradesDocId(space), 'revert failed escrow', (d) => { + d.trades[id].status = 'proposed' as any; + }); + return c.json({ error: `Escrow failed: ${lockResult.error}` }, 400); + } + } + + const final = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(final.trades[id]); + }); + + // ── POST /api/exchange/matches/:id/reject — Reject match ── + + routes.post('/api/exchange/matches/:id/reject', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.status !== 'proposed') return c.json({ error: `Trade status is ${trade.status}` }, 400); + + ss().changeDoc(exchangeTradesDocId(space), 'reject match', (d) => { + d.trades[id].status = 'cancelled' as any; + }); + + return c.json({ ok: true }); + }); + + // ── POST /api/exchange/trades/:id/fiat-sent — Buyer marks fiat sent ── + + routes.post('/api/exchange/trades/:id/fiat-sent', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.buyerDid !== claims.did) return c.json({ error: 'Only buyer can mark fiat sent' }, 403); + if (trade.status !== 'escrow_locked') return c.json({ error: `Expected escrow_locked, got ${trade.status}` }, 400); + + ss().changeDoc(exchangeTradesDocId(space), 'buyer marked fiat sent', (d) => { + d.trades[id].status = 'fiat_sent' as any; + }); + + const updated = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(updated.trades[id]); + }); + + // ── POST /api/exchange/trades/:id/confirm — Seller confirms fiat received ── + + routes.post('/api/exchange/trades/:id/confirm', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.sellerDid !== claims.did) return c.json({ error: 'Only seller can confirm fiat receipt' }, 403); + if (trade.status !== 'fiat_sent') return c.json({ error: `Expected fiat_sent, got ${trade.status}` }, 400); + + ss().changeDoc(exchangeTradesDocId(space), 'seller confirmed fiat', (d) => { + d.trades[id].status = 'fiat_confirmed' as any; + }); + + const freshDoc = ss().getDoc(exchangeTradesDocId(space))!; + const result = releaseEscrow(freshDoc.trades[id], ss(), space); + if (!result.success) { + return c.json({ error: `Release failed: ${result.error}` }, 500); + } + + const final = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(final.trades[id]); + }); + + // ── POST /api/exchange/trades/:id/dispute — Raise dispute ── + + routes.post('/api/exchange/trades/:id/dispute', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + const body = await c.req.json(); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.buyerDid !== claims.did && trade.sellerDid !== claims.did) { + return c.json({ error: 'Not a party to this trade' }, 403); + } + if (!['escrow_locked', 'fiat_sent', 'fiat_confirmed'].includes(trade.status)) { + return c.json({ error: `Cannot dispute in status ${trade.status}` }, 400); + } + + ss().changeDoc(exchangeTradesDocId(space), 'raise dispute', (d) => { + d.trades[id].status = 'disputed' as any; + if (body.reason) d.trades[id].disputeReason = body.reason as any; + }); + + // Track dispute in reputation + ensureReputationDoc(space); + ss().changeDoc(exchangeReputationDocId(space), 'track dispute', (d) => { + const disputerDid = claims.did as string; + if (!d.records[disputerDid]) { + d.records[disputerDid] = { + did: disputerDid, tradesCompleted: 0, tradesCancelled: 0, + disputesRaised: 0, disputesLost: 0, totalVolumeBase: 0, + avgConfirmTimeMs: 0, score: 50, badges: [], + } as any; + } + d.records[disputerDid].disputesRaised = (d.records[disputerDid].disputesRaised + 1) as any; + }); + + const updated = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(updated.trades[id]); + }); + + // ── POST /api/exchange/trades/:id/resolve — Admin resolve dispute ── + + routes.post('/api/exchange/trades/:id/resolve', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + // TODO: proper admin check — for MVP, any authenticated user can resolve + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + const body = await c.req.json(); + ensureTradesDoc(space); + ensureReputationDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.status !== 'disputed') return c.json({ error: `Expected disputed, got ${trade.status}` }, 400); + + const resolution = body.resolution as 'released_to_buyer' | 'returned_to_seller'; + if (resolution !== 'released_to_buyer' && resolution !== 'returned_to_seller') { + return c.json({ error: 'resolution must be released_to_buyer or returned_to_seller' }, 400); + } + + const ok = resolveDispute(trade, resolution, ss(), space); + if (!ok) return c.json({ error: 'Resolution failed' }, 500); + + const final = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(final.trades[id]); + }); + + // ── POST /api/exchange/trades/:id/message — Trade chat ── + + routes.post('/api/exchange/trades/:id/message', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + const body = await c.req.json(); + ensureTradesDoc(space); + + const doc = ss().getDoc(exchangeTradesDocId(space))!; + const trade = doc.trades[id]; + if (!trade) return c.json({ error: 'Trade not found' }, 404); + if (trade.buyerDid !== claims.did && trade.sellerDid !== claims.did) { + return c.json({ error: 'Not a party to this trade' }, 403); + } + if (!body.text?.trim()) return c.json({ error: 'text required' }, 400); + + const msgId = crypto.randomUUID(); + ss().changeDoc(exchangeTradesDocId(space), 'trade chat message', (d) => { + if (!d.trades[id].chatMessages) d.trades[id].chatMessages = [] as any; + (d.trades[id].chatMessages as any[]).push({ + id: msgId, + senderDid: claims.did, + senderName: claims.username || 'Unknown', + text: body.text.trim(), + timestamp: Date.now(), + }); + }); + + const updated = ss().getDoc(exchangeTradesDocId(space))!; + return c.json(updated.trades[id]); + }); + + // ── GET /api/exchange/reputation/:did — Reputation lookup ── + + routes.get('/api/exchange/reputation/:did', (c) => { + const space = c.req.param('space') || 'demo'; + const did = c.req.param('did'); + ensureReputationDoc(space); + const doc = ss().getDoc(exchangeReputationDocId(space))!; + return c.json(getReputation(did, doc)); + }); + + // ── GET /api/exchange/rate/:token/:fiat — Live rate ── + + routes.get('/api/exchange/rate/:token/:fiat', async (c) => { + const tokenId = c.req.param('token') as TokenId; + const fiat = c.req.param('fiat') as FiatCurrency; + + if (!VALID_TOKENS.includes(tokenId)) return c.json({ error: 'Invalid token' }, 400); + if (!VALID_FIATS.includes(fiat)) return c.json({ error: 'Invalid fiat currency' }, 400); + + const rate = await getExchangeRate(tokenId, fiat); + return c.json({ tokenId, fiatCurrency: fiat, rate }); + }); + + return routes; +} + +// ── Solver Cron ── + +let _solverInterval: ReturnType | null = null; + +/** + * Start the periodic solver cron. Runs every 60s. + */ +export function startSolverCron(getSyncServer: () => SyncServer | null) { + if (_solverInterval) return; + + _solverInterval = setInterval(async () => { + const syncServer = getSyncServer(); + if (!syncServer) return; + + // Get all spaces that have exchange intents + const allDocIds = syncServer.listDocs(); + const spaces = allDocIds + .filter(id => id.endsWith(':rexchange:intents')) + .map(id => id.split(':')[0]); + + for (const space of spaces) { + try { + const intentsDoc = syncServer.getDoc(exchangeIntentsDocId(space)); + const reputationDoc = syncServer.getDoc(exchangeReputationDocId(space)); + if (!intentsDoc || !reputationDoc) continue; + + const activeIntents = Object.values(intentsDoc.intents).filter(i => i.status === 'active'); + const hasBuys = activeIntents.some(i => i.side === 'buy'); + const hasSells = activeIntents.some(i => i.side === 'sell'); + if (!hasBuys || !hasSells) continue; + + // Run solver + const matches = await solveExchange(intentsDoc, reputationDoc); + if (matches.length === 0) continue; + + // Create trade entries for proposed matches + const tradesDocId = exchangeTradesDocId(space); + let tradesDoc = syncServer.getDoc(tradesDocId); + if (!tradesDoc) { + tradesDoc = Automerge.change(Automerge.init(), 'init exchange trades', (d) => { + const init = exchangeTradesSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + syncServer.setDoc(tradesDocId, tradesDoc); + } + + // Only create trades for new matches (avoid duplicates) + const existingPairs = new Set( + Object.values(tradesDoc.trades) + .filter(t => t.status === 'proposed' || t.status === 'accepted' || t.status === 'escrow_locked' || t.status === 'fiat_sent') + .map(t => `${t.buyIntentId}:${t.sellIntentId}`), + ); + + for (const match of matches) { + const pairKey = `${match.buyIntentId}:${match.sellIntentId}`; + if (existingPairs.has(pairKey)) continue; + + const tradeId = crypto.randomUUID(); + syncServer.changeDoc(tradesDocId, 'solver: propose trade', (d) => { + d.trades[tradeId] = { + id: tradeId, + ...match, + chatMessages: [], + createdAt: Date.now(), + } as any; + }); + + // If both parties have autoAccept, immediately accept and lock escrow + const created = syncServer.getDoc(tradesDocId)!; + const trade = created.trades[tradeId]; + if (trade.acceptances[trade.buyerDid] && trade.acceptances[trade.sellerDid]) { + syncServer.changeDoc(tradesDocId, 'auto-accept trade', (d) => { + d.trades[tradeId].status = 'accepted' as any; + }); + syncServer.changeDoc(exchangeIntentsDocId(space), 'auto-match intents', (d) => { + if (d.intents[trade.buyIntentId]) d.intents[trade.buyIntentId].status = 'matched' as any; + if (d.intents[trade.sellIntentId]) d.intents[trade.sellIntentId].status = 'matched' as any; + }); + + const freshDoc = syncServer.getDoc(tradesDocId)!; + lockEscrow(freshDoc.trades[tradeId], syncServer, space); + } + + console.log(`[rExchange] Solver proposed trade ${tradeId}: ${match.buyerName} ↔ ${match.sellerName} for ${match.tokenAmount} ${match.tokenId}`); + } + + // Sweep timeouts + sweepTimeouts(syncServer, space); + } catch (e) { + console.warn(`[rExchange] Solver error for space ${space}:`, e); + } + } + }, 60_000); +} + +export function stopSolverCron() { + if (_solverInterval) { + clearInterval(_solverInterval); + _solverInterval = null; + } +} diff --git a/modules/rexchange/exchange-settlement.ts b/modules/rexchange/exchange-settlement.ts new file mode 100644 index 0000000..5f875dd --- /dev/null +++ b/modules/rexchange/exchange-settlement.ts @@ -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( + 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( + 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( + 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( + 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(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( + 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( + 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(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( + exchangeReputationDocId(space), + 'update dispute loser', + (d) => { + if (d.records[loserDid]) { + d.records[loserDid].disputesLost = (d.records[loserDid].disputesLost + 1) as any; + } + }, + ); +} diff --git a/modules/rexchange/exchange-solver.ts b/modules/rexchange/exchange-solver.ts new file mode 100644 index 0000000..4285ad7 --- /dev/null +++ b/modules/rexchange/exchange-solver.ts @@ -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[]> { + 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(); + 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(); + const results: Omit[] = []; + + 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, + }; +} diff --git a/modules/rexchange/landing.ts b/modules/rexchange/landing.ts new file mode 100644 index 0000000..dec735c --- /dev/null +++ b/modules/rexchange/landing.ts @@ -0,0 +1,239 @@ +/** + * rExchange landing page — P2P crypto/fiat exchange within communities. + */ +export function renderLanding(): string { + return ` + +
+ + Part of the rSpace Ecosystem + +

+ Community
Exchange +

+

+ A peer-to-peer on/off-ramp 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. +

+ +
+ + +
+
+
+ ELI5 +

+ What is P2P Exchange? +

+

+ Think LocalBitcoins inside your rSpace community. + Members post buy/sell intents, the solver finds matches, and escrow handles trustless settlement. +

+
+ +
+ +
+
+
+ $ +
+

Post Intents

+
+

+ Buy or sell cUSDC, $MYCO, or fUSDC. Set your price, amount range, and accepted payment + methods (SEPA, Revolut, PIX, M-Pesa, Cash). + Standing orders let you be a liquidity provider. +

+
+ + +
+
+
+ +
+

Solver Matching

+
+

+ The matching engine runs every 60s, finding compatible buy/sell pairs by token, currency, + rate overlap, and reputation. Scored and ranked automatically. + Auto-accept for hands-free trading. +

+
+ + +
+
+
+ +
+

Escrow & Trust

+
+

+ Seller's tokens are locked in CRDT escrow. Buyer sends fiat off-chain, seller confirms + receipt, tokens release. 24h timeout auto-reverses. + Disputes resolved by space admins. +

+
+
+
+
+ + +
+
+
+ + How It Works + +

+ Intent → Match → Settle +

+
+ +
+
+
+
+ 1 +
+
+ Step 1 +

Post Intent

+
+
+

+ "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. +

+
+ +
+
+
+ 2 +
+
+ Step 2 +

Match & Escrow

+
+
+

+ Solver finds your counterparty. Both accept the match. Seller's tokens lock in escrow. + In-trade chat for coordinating fiat payment details. +

+
+ +
+
+
+ 3 +
+
+ Step 3 +

Settle & Build Rep

+
+
+

+ Buyer sends fiat, seller confirms receipt, tokens release instantly. Both parties earn + reputation score. Standing orders re-activate for continuous liquidity. +

+
+
+
+
+ + +
+
+
+

+ Built for Global Communities +

+
+ +
+
+
+ 🌍 +
+

8 Fiat Currencies

+

EUR, USD, GBP, BRL, MXN, INR, NGN, ARS with CoinGecko live rates.

+
+ +
+
+ 🔒 +
+

CRDT Escrow

+

Trustless token escrow with 24h timeout. Net supply neutral settlement.

+
+ +
+
+ +
+

Reputation

+

Score based on completion rate, dispute history, and confirmation speed. Badges earned.

+
+ +
+
+ 🤖 +
+

Auto-Matching

+

Intent solver runs every 60s. Auto-accept for hands-free LP trading.

+
+
+
+
+ + +
+
+
+ + Join the rSpace Ecosystem + +

+ Ready to exchange with your community? +

+

+ Create a Space and become a liquidity node. Post standing orders to provide exchange + access for your community — earn reputation and enable financial inclusion. +

+ +
+
+
+ +`; +} diff --git a/modules/rexchange/mod.ts b/modules/rexchange/mod.ts new file mode 100644 index 0000000..d35b3e2 --- /dev/null +++ b/modules/rexchange/mod.ts @@ -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: ``, + scripts: ``, + })); +}); + +// ── 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' }, + ], +}; diff --git a/modules/rexchange/schemas.ts b/modules/rexchange/schemas.ts new file mode 100644 index 0000000..4a42b90 --- /dev/null +++ b/modules/rexchange/schemas.ts @@ -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; // 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; +} + +export interface ExchangeTradesDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + trades: Record; +} + +export interface ExchangeReputationDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + records: Record; +} + +// ── 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 = { + module: 'rexchange', + collection: 'intents', + version: 1, + init: (): ExchangeIntentsDoc => ({ + meta: { + module: 'rexchange', + collection: 'intents', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + intents: {}, + }), +}; + +export const exchangeTradesSchema: DocSchema = { + module: 'rexchange', + collection: 'trades', + version: 1, + init: (): ExchangeTradesDoc => ({ + meta: { + module: 'rexchange', + collection: 'trades', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + trades: {}, + }), +}; + +export const exchangeReputationSchema: DocSchema = { + module: 'rexchange', + collection: 'reputation', + version: 1, + init: (): ExchangeReputationDoc => ({ + meta: { + module: 'rexchange', + collection: 'reputation', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + records: {}, + }), +}; diff --git a/server/index.ts b/server/index.ts index c5b53e3..5c9d7fa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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); From efb7ee5600d48f5a0cd187a76fffe9216c0f090b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 18:57:59 -0400 Subject: [PATCH 8/8] fix(rexchange): add demo seeding and server-rendered order book page Replace missing folk-exchange-app.js with server-rendered HTML order book. Seed 8 demo intents, 2 trades, and 5 reputation records on startup. Co-Authored-By: Claude Opus 4.6 --- modules/rexchange/mod.ts | 352 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 349 insertions(+), 3 deletions(-) diff --git a/modules/rexchange/mod.ts b/modules/rexchange/mod.ts index d35b3e2..f36f846 100644 --- a/modules/rexchange/mod.ts +++ b/modules/rexchange/mod.ts @@ -12,6 +12,7 @@ */ import { Hono } from 'hono'; +import * as Automerge from '@automerge/automerge'; import { renderShell } from '../../server/shell'; import { getModuleInfoList } from '../../shared/module'; import type { RSpaceModule } from '../../shared/module'; @@ -19,8 +20,13 @@ import type { SyncServer } from '../../server/local-first/sync-server'; import { renderLanding } from './landing'; import { exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema, + exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, } from './schemas'; -import { createExchangeRoutes, startSolverCron, stopSolverCron } from './exchange-routes'; +import type { + ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, + ExchangeIntent, ExchangeTrade, ExchangeReputationRecord, +} from './schemas'; +import { createExchangeRoutes, startSolverCron } from './exchange-routes'; const routes = new Hono(); @@ -31,6 +37,345 @@ let _syncServer: SyncServer | null = null; const exchangeRoutes = createExchangeRoutes(() => _syncServer); routes.route('/', exchangeRoutes); +// ── Automerge helpers ── + +function ensureIntentsDoc(space: string): ExchangeIntentsDoc { + const docId = exchangeIntentsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init exchange intents', (d) => { + const init = exchangeIntentsSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function ensureTradesDoc(space: string): ExchangeTradesDoc { + const docId = exchangeTradesDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init exchange trades', (d) => { + const init = exchangeTradesSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function ensureReputationDoc(space: string): ExchangeReputationDoc { + const docId = exchangeReputationDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init exchange reputation', (d) => { + const init = exchangeReputationSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +// ── Demo seeding ── + +const DEMO_DIDS = { + alice: 'did:key:alice-contributor-rspace-2026', + bob: 'did:key:bob-auditor-rspace-2026', + carol: 'did:key:carol-designer-rspace-2026', + maya: 'did:key:maya-facilitator-rspace-2026', + jordan: 'did:key:jordan-designer-rspace-2026', +}; + +const DEMO_INTENTS: Omit[] = [ + // Buys (want crypto, have fiat) + { + creatorDid: DEMO_DIDS.alice, creatorName: 'Alice', + side: 'buy', tokenId: 'cusdc', fiatCurrency: 'EUR', + tokenAmountMin: 50_000_000, tokenAmountMax: 200_000_000, + rateType: 'market_plus_bps', rateMarketBps: 50, + paymentMethods: ['SEPA', 'Revolut'], isStandingOrder: false, + autoAccept: false, allowInstitutionalFallback: true, status: 'active', + }, + { + creatorDid: DEMO_DIDS.maya, creatorName: 'Maya', + side: 'buy', tokenId: 'cusdc', fiatCurrency: 'USD', + tokenAmountMin: 100_000_000, tokenAmountMax: 500_000_000, + rateType: 'fixed', rateFixed: 1.00, + paymentMethods: ['Revolut', 'Cash'], isStandingOrder: true, + autoAccept: true, allowInstitutionalFallback: false, status: 'active', + }, + { + creatorDid: DEMO_DIDS.jordan, creatorName: 'Jordan', + side: 'buy', tokenId: 'myco', fiatCurrency: 'BRL', + tokenAmountMin: 1_000_000_000, tokenAmountMax: 5_000_000_000, + rateType: 'market_plus_bps', rateMarketBps: 100, + paymentMethods: ['PIX'], isStandingOrder: false, + autoAccept: false, allowInstitutionalFallback: true, status: 'active', + }, + { + creatorDid: DEMO_DIDS.carol, creatorName: 'Carol', + side: 'buy', tokenId: 'cusdc', fiatCurrency: 'GBP', + tokenAmountMin: 25_000_000, tokenAmountMax: 100_000_000, + rateType: 'fixed', rateFixed: 0.79, + paymentMethods: ['SEPA', 'Revolut'], isStandingOrder: false, + autoAccept: false, allowInstitutionalFallback: false, status: 'active', + }, + + // Sells (have crypto, want fiat) + { + creatorDid: DEMO_DIDS.bob, creatorName: 'Bob', + side: 'sell', tokenId: 'cusdc', fiatCurrency: 'EUR', + tokenAmountMin: 100_000_000, tokenAmountMax: 300_000_000, + rateType: 'market_plus_bps', rateMarketBps: 30, + paymentMethods: ['SEPA'], isStandingOrder: true, + autoAccept: true, allowInstitutionalFallback: false, status: 'active', + }, + { + creatorDid: DEMO_DIDS.carol, creatorName: 'Carol', + side: 'sell', tokenId: 'cusdc', fiatCurrency: 'USD', + tokenAmountMin: 50_000_000, tokenAmountMax: 250_000_000, + rateType: 'fixed', rateFixed: 1.01, + paymentMethods: ['Revolut', 'Cash'], isStandingOrder: false, + autoAccept: false, allowInstitutionalFallback: false, status: 'active', + }, + { + creatorDid: DEMO_DIDS.alice, creatorName: 'Alice', + side: 'sell', tokenId: 'myco', fiatCurrency: 'EUR', + tokenAmountMin: 500_000_000, tokenAmountMax: 2_000_000_000, + rateType: 'market_plus_bps', rateMarketBps: 75, + paymentMethods: ['SEPA', 'Revolut'], isStandingOrder: false, + autoAccept: false, allowInstitutionalFallback: true, status: 'active', + }, + { + creatorDid: DEMO_DIDS.maya, creatorName: 'Maya', + side: 'sell', tokenId: 'cusdc', fiatCurrency: 'GBP', + tokenAmountMin: 30_000_000, tokenAmountMax: 150_000_000, + rateType: 'fixed', rateFixed: 0.80, + paymentMethods: ['SEPA', 'Revolut'], isStandingOrder: true, + autoAccept: true, allowInstitutionalFallback: false, status: 'active', + }, +]; + +const DEMO_REPUTATION: ExchangeReputationRecord[] = [ + { did: DEMO_DIDS.alice, tradesCompleted: 12, tradesCancelled: 1, disputesRaised: 0, disputesLost: 0, totalVolumeBase: 2_500_000_000, avgConfirmTimeMs: 1800_000, score: 88, badges: ['verified_seller'] }, + { did: DEMO_DIDS.bob, tradesCompleted: 27, tradesCancelled: 0, disputesRaised: 1, disputesLost: 0, totalVolumeBase: 8_000_000_000, avgConfirmTimeMs: 900_000, score: 95, badges: ['verified_seller'] }, + { did: DEMO_DIDS.carol, tradesCompleted: 6, tradesCancelled: 2, disputesRaised: 1, disputesLost: 1, totalVolumeBase: 1_200_000_000, avgConfirmTimeMs: 3600_000, score: 65, badges: [] }, + { did: DEMO_DIDS.maya, tradesCompleted: 45, tradesCancelled: 1, disputesRaised: 0, disputesLost: 0, totalVolumeBase: 15_000_000_000, avgConfirmTimeMs: 600_000, score: 97, badges: ['verified_seller', 'liquidity_provider', 'top_trader'] }, + { did: DEMO_DIDS.jordan, tradesCompleted: 3, tradesCancelled: 0, disputesRaised: 0, disputesLost: 0, totalVolumeBase: 500_000_000, avgConfirmTimeMs: 2400_000, score: 72, badges: [] }, +]; + +const DEMO_TRADES: Omit[] = [ + { + buyIntentId: '', sellIntentId: '', + buyerDid: DEMO_DIDS.alice, buyerName: 'Alice', + sellerDid: DEMO_DIDS.bob, sellerName: 'Bob', + tokenId: 'cusdc', tokenAmount: 150_000_000, fiatCurrency: 'EUR', + fiatAmount: 138.75, agreedRate: 0.925, paymentMethod: 'SEPA', + status: 'completed', acceptances: { [DEMO_DIDS.alice]: true, [DEMO_DIDS.bob]: true }, + chatMessages: [ + { id: 'msg-1', senderDid: DEMO_DIDS.alice, senderName: 'Alice', text: 'SEPA sent, ref: ALICE-BOB-001', timestamp: Date.now() - 86400_000 * 3 }, + { id: 'msg-2', senderDid: DEMO_DIDS.bob, senderName: 'Bob', text: 'Received, releasing escrow', timestamp: Date.now() - 86400_000 * 3 + 1800_000 }, + ], + completedAt: Date.now() - 86400_000 * 3, + }, + { + buyIntentId: '', sellIntentId: '', + buyerDid: DEMO_DIDS.jordan, buyerName: 'Jordan', + sellerDid: DEMO_DIDS.carol, sellerName: 'Carol', + tokenId: 'cusdc', tokenAmount: 75_000_000, fiatCurrency: 'USD', + fiatAmount: 75.50, agreedRate: 1.007, paymentMethod: 'Revolut', + status: 'escrow_locked', acceptances: { [DEMO_DIDS.jordan]: true, [DEMO_DIDS.carol]: true }, + chatMessages: [], + fiatConfirmDeadline: Date.now() + 86400_000, + }, +]; + +function seedDemoIfEmpty(space: string = 'demo') { + if (!_syncServer) return; + const existing = _syncServer.getDoc(exchangeIntentsDocId(space)); + if (existing && Object.keys(existing.intents).length > 0) return; + + const now = Date.now(); + + // Seed intents + ensureIntentsDoc(space); + _syncServer.changeDoc(exchangeIntentsDocId(space), 'seed exchange intents', (d) => { + for (const intent of DEMO_INTENTS) { + const id = crypto.randomUUID(); + d.intents[id] = { id, ...intent, createdAt: now } as any; + } + }); + + // Seed trades + ensureTradesDoc(space); + _syncServer.changeDoc(exchangeTradesDocId(space), 'seed exchange trades', (d) => { + for (const trade of DEMO_TRADES) { + const id = crypto.randomUUID(); + d.trades[id] = { id, ...trade, createdAt: now - 86400_000 * 5 } as any; + } + }); + + // Seed reputation + ensureReputationDoc(space); + _syncServer.changeDoc(exchangeReputationDocId(space), 'seed exchange reputation', (d) => { + for (const rec of DEMO_REPUTATION) { + d.records[rec.did] = rec as any; + } + }); + + console.log(`[rExchange] Demo data seeded for "${space}": ${DEMO_INTENTS.length} intents, ${DEMO_TRADES.length} trades, ${DEMO_REPUTATION.length} reputation records`); +} + +// ── Server-rendered order book page ── + +function renderOrderBook(space: string): string { + const intentsDoc = _syncServer?.getDoc(exchangeIntentsDocId(space)); + const tradesDoc = _syncServer?.getDoc(exchangeTradesDocId(space)); + const repDoc = _syncServer?.getDoc(exchangeReputationDocId(space)); + + const intents = intentsDoc ? Object.values(intentsDoc.intents) : []; + const trades = tradesDoc ? Object.values(tradesDoc.trades) : []; + + const buyIntents = intents.filter(i => i.status === 'active' && i.side === 'buy'); + const sellIntents = intents.filter(i => i.status === 'active' && i.side === 'sell'); + const activeTrades = trades.filter(t => !['completed', 'cancelled', 'timed_out', 'resolved'].includes(t.status)); + const completedTrades = trades.filter(t => t.status === 'completed'); + + function fmtAmount(base: number) { + return (base / 1_000_000).toFixed(2); + } + + function rateStr(i: { rateType: string; rateFixed?: number; rateMarketBps?: number; fiatCurrency: string }) { + if (i.rateType === 'fixed') return `${i.rateFixed} ${i.fiatCurrency}`; + return `mkt+${i.rateMarketBps}bps`; + } + + function statusBadge(s: string) { + const colors: Record = { + proposed: '#f59e0b', accepted: '#3b82f6', escrow_locked: '#8b5cf6', + fiat_sent: '#f97316', fiat_confirmed: '#10b981', completed: '#22c55e', + disputed: '#ef4444', resolved: '#6b7280', cancelled: '#6b7280', timed_out: '#6b7280', + }; + const c = colors[s] || '#6b7280'; + return `${s.replace(/_/g, ' ')}`; + } + + function repBadge(did: string) { + const rec = repDoc?.records[did]; + if (!rec) return 'new'; + const c = rec.score >= 80 ? '#22c55e' : rec.score >= 60 ? '#f59e0b' : '#ef4444'; + const badges = rec.badges.length ? ` ${rec.badges.map(b => b === 'verified_seller' ? '✓' : b === 'liquidity_provider' ? '💧' : b === 'top_trader' ? '🏆' : '').join('')}` : ''; + return `${rec.score}${badges}`; + } + + function intentRow(i: typeof intents[0]) { + const sideColor = i.side === 'buy' ? '#10b981' : '#f59e0b'; + const icon = i.tokenId === 'cusdc' ? '💵' : i.tokenId === 'myco' ? '🌱' : '🎮'; + return ` + ${i.side} + ${icon} ${i.tokenId} + ${fmtAmount(i.tokenAmountMin)}–${fmtAmount(i.tokenAmountMax)} + ${rateStr(i)} + ${i.paymentMethods.join(', ')} + ${i.creatorName} ${repBadge(i.creatorDid)} + ${i.isStandingOrder ? 'LP' : ''} ${i.autoAccept ? 'auto' : ''} + `; + } + + function tradeRow(t: typeof trades[0]) { + const icon = t.tokenId === 'cusdc' ? '💵' : t.tokenId === 'myco' ? '🌱' : '🎮'; + return ` + ${icon} ${fmtAmount(t.tokenAmount)} ${t.tokenId} + ${t.fiatAmount.toFixed(2)} ${t.fiatCurrency} + ${t.buyerName} ${repBadge(t.buyerDid)} + ${t.sellerName} ${repBadge(t.sellerDid)} + ${t.paymentMethod} + ${statusBadge(t.status)} + `; + } + + return ` +
+
+
+

+ 💱 rExchange +

+

P2P order book for ${space}

+
+
+ ${buyIntents.length} buys + ${sellIntents.length} sells + ${activeTrades.length} active + ${completedTrades.length} settled +
+
+ + +
+

Order Book

+ ${intents.filter(i => i.status === 'active').length === 0 + ? '

No active intents

' + : ` + + + + + + + + + + ${buyIntents.map(intentRow).join('')}${sellIntents.map(intentRow).join('')} +
SideTokenAmountRatePaymentTraderFlags
`} +
+ + + ${activeTrades.length > 0 ? ` +
+

Active Trades

+ + + + + + + + + + ${activeTrades.map(tradeRow).join('')} +
AmountFiatBuyerSellerMethodStatus
+
` : ''} + + + ${completedTrades.length > 0 ? ` +
+

Recent Trades

+ + + + + + + + + + ${completedTrades.map(tradeRow).join('')} +
AmountFiatBuyerSellerMethodStatus
+
` : ''} +
+ +`; +} + // ── Page routes ── routes.get('/', (c) => { @@ -41,8 +386,7 @@ routes.get('/', (c) => { spaceSlug: space, modules: getModuleInfoList(), theme: 'dark', - body: ``, - scripts: ``, + body: renderOrderBook(space), })); }); @@ -63,8 +407,10 @@ export const exchangeModule: RSpaceModule = { ], routes, landingPage: renderLanding, + seedTemplate: seedDemoIfEmpty, async onInit(ctx) { _syncServer = ctx.syncServer; + seedDemoIfEmpty(); startSolverCron(() => _syncServer); }, feeds: [