@@ -311,6 +382,7 @@ function renderOrderBook(space: string): string {
${buyIntents.length} buys
${sellIntents.length} sells
+ ${activePositions.length} pools
${activeTrades.length} active
${completedTrades.length} settled
@@ -335,6 +407,25 @@ function renderOrderBook(space: string): string {
`}
+
+ ${activePositions.length > 0 ? `
+
+
+ 💧 Liquidity Pools
+
+
+
+ | Provider |
+ Pair |
+ Depth |
+ Spread |
+ Earned |
+ Trades |
+
+ ${activePositions.map(poolRow).join('')}
+
+
` : ''}
+
${activeTrades.length > 0 ? `
@@ -404,6 +495,7 @@ export const exchangeModule: RSpaceModule = {
{ 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 },
+ { pattern: '{space}:rexchange:pools', description: 'Liquidity pool positions', init: exchangePoolsSchema.init },
],
routes,
landingPage: renderLanding,
@@ -428,5 +520,6 @@ export const exchangeModule: RSpaceModule = {
],
onboardingActions: [
{ label: 'Post Intent', icon: '💱', description: 'Post a buy or sell intent', type: 'create', href: '/rexchange' },
+ { label: 'Add Liquidity', icon: '💧', description: 'Provide liquidity to earn the spread', type: 'create', href: '/rexchange' },
],
};
diff --git a/modules/rexchange/schemas.ts b/modules/rexchange/schemas.ts
index 4a42b90..9c52cc4 100644
--- a/modules/rexchange/schemas.ts
+++ b/modules/rexchange/schemas.ts
@@ -91,6 +91,50 @@ export interface ExchangeTrade {
completedAt?: number;
}
+// ── Liquidity Pools ──
+
+export type PoolStatus = 'active' | 'paused' | 'withdrawn';
+
+export interface LiquidityPosition {
+ id: string;
+ creatorDid: string;
+ creatorName: string;
+ tokenId: TokenId;
+ fiatCurrency: FiatCurrency;
+ // Token side (sellable)
+ tokenCommitted: number; // base units locked for selling
+ tokenRemaining: number; // decrements as sells fill
+ // Fiat side (buyable)
+ fiatCommitted: number; // fiat amount committed for buying tokens
+ fiatRemaining: number; // decrements as buys fill
+ // Spread = the LP's fee (applied as market_plus_bps on both sides)
+ spreadBps: number; // e.g., 50 = 0.5% each side, 1% round-trip
+ // Payment
+ paymentMethods: string[];
+ // Linked standing order IDs (created automatically)
+ buyIntentId: string;
+ sellIntentId: string;
+ // Earnings tracking
+ feesEarnedToken: number; // token base units earned from spread
+ feesEarnedFiat: number; // fiat earned from spread
+ tradesMatched: number;
+ // State
+ status: PoolStatus;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface ExchangePoolsDoc {
+ meta: {
+ module: string;
+ collection: string;
+ version: number;
+ spaceSlug: string;
+ createdAt: number;
+ };
+ positions: Record;
+}
+
// ── Reputation ──
export interface ExchangeReputationRecord {
@@ -154,6 +198,10 @@ export function exchangeReputationDocId(space: string) {
return `${space}:rexchange:reputation` as const;
}
+export function exchangePoolsDocId(space: string) {
+ return `${space}:rexchange:pools` as const;
+}
+
// ── Schema registrations ──
export const exchangeIntentsSchema: DocSchema = {
@@ -203,3 +251,19 @@ export const exchangeReputationSchema: DocSchema = {
records: {},
}),
};
+
+export const exchangePoolsSchema: DocSchema = {
+ module: 'rexchange',
+ collection: 'pools',
+ version: 1,
+ init: (): ExchangePoolsDoc => ({
+ meta: {
+ module: 'rexchange',
+ collection: 'pools',
+ version: 1,
+ spaceSlug: '',
+ createdAt: Date.now(),
+ },
+ positions: {},
+ }),
+};
diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts
index f6360cf..a03dc72 100644
--- a/modules/rtime/components/folk-timebank-app.ts
+++ b/modules/rtime/components/folk-timebank-app.ts
@@ -27,7 +27,8 @@ const EXEC_STEPS: Record n.id === s.fromId);
+ if (fromNode) {
+ const cx = fromNode.x + fromNode.w / 2, cy = fromNode.y + fromNode.h / 2;
+ const pts = hexPoints(cx, cy, fromNode.hexR || HEX_R);
+ const x1 = pts[1][0], y1 = pts[1][1];
+ const skills = Object.keys(s.toNode.data.needs);
+ const idx = skills.indexOf(s.skill);
+ const x2 = s.toNode.x;
+ const y2 = idx >= 0 ? s.toNode.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2 : s.toNode.y + s.toNode.h / 2;
+
+ // Pulsing dashed preview wire
+ const preview = ns('path');
+ preview.setAttribute('d', bezier(x1, y1, x2, y2));
+ preview.setAttribute('stroke', '#38bdf8');
+ preview.setAttribute('stroke-width', '2.5');
+ preview.setAttribute('stroke-dasharray', '8 4');
+ preview.setAttribute('fill', 'none');
+ preview.setAttribute('opacity', '0.8');
+ const anim = ns('animate');
+ anim.setAttribute('attributeName', 'stroke-dashoffset');
+ anim.setAttribute('values', '0;24');
+ anim.setAttribute('dur', '1s');
+ anim.setAttribute('repeatCount', 'indefinite');
+ preview.appendChild(anim);
+ this.connectionsLayer.appendChild(preview);
+
+ // Suggestion label + buttons at midpoint
+ const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
+ const sugLabel = svgText('Connect ' + s.hours + 'h ' + s.skill + '?', mx, my - 14, 11, '#38bdf8', '600', 'middle');
+ this.connectionsLayer.appendChild(sugLabel);
+
+ // Confirm button
+ const confirmBg = ns('rect');
+ confirmBg.setAttribute('x', String(mx - 40)); confirmBg.setAttribute('y', String(my - 2));
+ confirmBg.setAttribute('width', '36'); confirmBg.setAttribute('height', '20');
+ confirmBg.setAttribute('rx', '4'); confirmBg.setAttribute('fill', '#10b981');
+ confirmBg.setAttribute('class', 'suggest-confirm-btn');
+ confirmBg.style.cursor = 'pointer';
+ this.connectionsLayer.appendChild(confirmBg);
+ const confirmT = svgText('\u2713 Yes', mx - 22, my + 14, 10, '#fff', '700', 'middle');
+ confirmT.style.pointerEvents = 'none';
+ this.connectionsLayer.appendChild(confirmT);
+
+ // Dismiss button
+ const dismissBg = ns('rect');
+ dismissBg.setAttribute('x', String(mx + 4)); dismissBg.setAttribute('y', String(my - 2));
+ dismissBg.setAttribute('width', '36'); dismissBg.setAttribute('height', '20');
+ dismissBg.setAttribute('rx', '4'); dismissBg.setAttribute('fill', '#ef4444');
+ dismissBg.setAttribute('class', 'suggest-dismiss-btn');
+ dismissBg.style.cursor = 'pointer';
+ this.connectionsLayer.appendChild(dismissBg);
+ const dismissT = svgText('\u2717 No', mx + 22, my + 14, 10, '#fff', '700', 'middle');
+ dismissT.style.pointerEvents = 'none';
+ this.connectionsLayer.appendChild(dismissT);
+ }
+ }
}
private renderNode(node: WeaveNode): SVGGElement {
@@ -1356,8 +1420,12 @@ class FolkTimebankApp extends HTMLElement {
const t = node.data as TaskData;
const skills = Object.keys(t.needs);
const totalNeeded = Object.values(t.needs).reduce((a, b) => a + b, 0);
- const totalFulfilled = Object.values(t.fulfilled || {}).reduce((a, b) => a + b, 0);
- const progress = totalNeeded > 0 ? Math.min(1, totalFulfilled / totalNeeded) : 0;
+
+ // Compute committed / proposed totals across all skills from live wires
+ const taskWires = this.connections.filter(w => w.to === node.id);
+ const totalCommitted = taskWires.filter(w => w.status === 'committed').reduce((s, w) => s + w.hours, 0);
+ const totalProposed = taskWires.filter(w => w.status === 'proposed').reduce((s, w) => s + w.hours, 0);
+ const funded = totalNeeded > 0 && totalCommitted >= totalNeeded;
const ready = this.isTaskReady(node);
node.h = node.baseH! + (ready ? EXEC_BTN_H + 8 : 0);
@@ -1383,17 +1451,42 @@ class FolkTimebankApp extends HTMLElement {
const editPencil = svgText('\u270E', node.w - 10, 18, 11, '#ffffff66', '400', 'middle');
editPencil.style.pointerEvents = 'none';
g.appendChild(editPencil);
- g.appendChild(svgText(ready ? 'Ready!' : Math.round(progress * 100) + '%', node.w - 24, 18, 10, '#ffffffcc', '500', 'end'));
+ const pctText = funded ? 'Funded!' : (ready ? 'Ready!' : Math.round(((totalCommitted + totalProposed) / Math.max(1, totalNeeded)) * 100) + '%');
+ g.appendChild(svgText(pctText, node.w - 24, 18, 10, '#ffffffcc', '500', 'end'));
- const pbW = node.w - 24;
- const pbBg = ns('rect');
- pbBg.setAttribute('x', '12'); pbBg.setAttribute('y', '36'); pbBg.setAttribute('width', String(pbW)); pbBg.setAttribute('height', '4');
- pbBg.setAttribute('rx', '2'); pbBg.setAttribute('fill', '#334155');
- g.appendChild(pbBg);
- const pbF = ns('rect');
- pbF.setAttribute('x', '12'); pbF.setAttribute('y', '36'); pbF.setAttribute('width', String(progress * pbW)); pbF.setAttribute('height', '4');
- pbF.setAttribute('rx', '2'); pbF.setAttribute('fill', hCol);
- g.appendChild(pbF);
+ // ── Gas tank fuel gauge ──
+ const barW = node.w - GAS_TANK_PAD * 2;
+ const barBg = ns('rect');
+ barBg.setAttribute('x', String(GAS_TANK_PAD)); barBg.setAttribute('y', String(GAS_TANK_Y));
+ barBg.setAttribute('width', String(barW)); barBg.setAttribute('height', String(GAS_TANK_H));
+ barBg.setAttribute('rx', '4'); barBg.setAttribute('fill', '#1e293b');
+ g.appendChild(barBg);
+ if (totalNeeded > 0) {
+ const cFrac = Math.min(1, totalCommitted / totalNeeded);
+ const pFrac = Math.min(1 - cFrac, totalProposed / totalNeeded);
+ if (cFrac > 0) {
+ const cBar = ns('rect');
+ cBar.setAttribute('x', String(GAS_TANK_PAD)); cBar.setAttribute('y', String(GAS_TANK_Y));
+ cBar.setAttribute('width', String(cFrac * barW)); cBar.setAttribute('height', String(GAS_TANK_H));
+ cBar.setAttribute('rx', '4'); cBar.setAttribute('fill', '#10b981');
+ if (funded) cBar.setAttribute('filter', 'url(#glowGreen)');
+ g.appendChild(cBar);
+ }
+ if (pFrac > 0) {
+ const pBar = ns('rect');
+ pBar.setAttribute('x', String(GAS_TANK_PAD + cFrac * barW)); pBar.setAttribute('y', String(GAS_TANK_Y));
+ pBar.setAttribute('width', String(pFrac * barW)); pBar.setAttribute('height', String(GAS_TANK_H));
+ pBar.setAttribute('rx', '4'); pBar.setAttribute('fill', '#f59e0b');
+ g.appendChild(pBar);
+ }
+ }
+ // Text overlay on bar
+ const tankLabel = funded
+ ? 'Funded!'
+ : totalCommitted + 'c + ' + totalProposed + 'p / ' + totalNeeded + ' hrs';
+ const tankText = svgText(tankLabel, node.w / 2, GAS_TANK_Y + GAS_TANK_H / 2 + 4, 9, '#fff', '600', 'middle');
+ tankText.style.pointerEvents = 'none';
+ g.appendChild(tankText);
skills.forEach((skill, i) => {
const ry = TASK_H_BASE + i * TASK_ROW;
@@ -1490,6 +1583,16 @@ class FolkTimebankApp extends HTMLElement {
return;
}
+ // Mycelial suggestion confirm/dismiss
+ if ((e.target as Element).classList.contains('suggest-confirm-btn')) {
+ this.acceptSuggestion();
+ return;
+ }
+ if ((e.target as Element).classList.contains('suggest-dismiss-btn')) {
+ this.dismissSuggestion();
+ return;
+ }
+
// Approve/decline buttons on commitment hexagons
if ((e.target as Element).classList.contains('approve-btn')) {
const connId = (e.target as SVGElement).getAttribute('data-connection-id');
@@ -1768,9 +1871,35 @@ class FolkTimebankApp extends HTMLElement {
}
}
} else {
- // Dropped on open canvas — place commitment node without connection
- if (!this.weaveNodes.find(n => n.id === 'cn-' + c.id)) {
- this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2));
+ // Dropped on open canvas — find nearest matching task and suggest connection
+ const matchTask = this.findNearestUnfulfilledTask(pt.x, pt.y, c.skill);
+ if (matchTask) {
+ const available = this.availableHours(c.id);
+ const needed = (matchTask.data.needs[c.skill] || 0) - ((matchTask.data.fulfilled || {})[c.skill] || 0);
+ const allocate = Math.min(available, Math.max(0, needed));
+ if (allocate > 0) {
+ // Place commitment node offset left of the task's input port
+ const commitX = matchTask.x - 180;
+ const skills = Object.keys(matchTask.data.needs);
+ const skillIdx = skills.indexOf(c.skill);
+ const portY = matchTask.y + TASK_H_BASE + (skillIdx >= 0 ? skillIdx : 0) * TASK_ROW + TASK_ROW / 2;
+ const commitY = portY - NODE_H / 2;
+ const fromId = 'cn-' + c.id;
+ if (!this.weaveNodes.find(n => n.id === fromId)) {
+ this.weaveNodes.push(this.mkCommitNode(c, commitX, commitY));
+ }
+ this.pendingSuggestion = { fromId, toNode: matchTask, skill: c.skill, hours: allocate, commitX, commitY };
+ } else {
+ // No hours to allocate — just place the node
+ if (!this.weaveNodes.find(n => n.id === 'cn-' + c.id)) {
+ this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2));
+ }
+ }
+ } else {
+ // No matching task found — place commitment node without connection
+ if (!this.weaveNodes.find(n => n.id === 'cn-' + c.id)) {
+ this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2));
+ }
}
}
@@ -1814,6 +1943,46 @@ class FolkTimebankApp extends HTMLElement {
return null;
}
+ /** Find the nearest task node that needs the given skill and isn't fully fulfilled. */
+ private findNearestUnfulfilledTask(x: number, y: number, skill: string): WeaveNode | null {
+ let best: WeaveNode | null = null;
+ let bestDist = Infinity;
+ for (const node of this.weaveNodes) {
+ if (node.type !== 'task') continue;
+ const t = node.data as TaskData;
+ const needed = t.needs[skill];
+ if (!needed) continue;
+ const fulfilled = (t.fulfilled || {})[skill] || 0;
+ if (fulfilled >= needed) continue;
+ const dx = x - node.x, dy = y - (node.y + node.h / 2);
+ const dist = dx * dx + dy * dy;
+ if (dist < bestDist) { bestDist = dist; best = node; }
+ }
+ return best;
+ }
+
+ /** Accept a pending mycelial suggestion — create the wire. */
+ private acceptSuggestion() {
+ const s = this.pendingSuggestion;
+ if (!s) return;
+ if (!this.connections.find(w => w.from === s.fromId && w.to === s.toNode.id && w.skill === s.skill)) {
+ this.connections.push({ from: s.fromId, to: s.toNode.id, skill: s.skill, hours: s.hours, status: 'proposed' });
+ if (!s.toNode.data.fulfilled) s.toNode.data.fulfilled = {};
+ s.toNode.data.fulfilled[s.skill] = (s.toNode.data.fulfilled[s.skill] || 0) + s.hours;
+ this.persistConnection(s.fromId, s.toNode.id, s.skill, s.hours);
+ }
+ this.pendingSuggestion = null;
+ this.buildOrbs();
+ this.renderAll();
+ this.rebuildSidebar();
+ }
+
+ /** Dismiss the pending mycelial suggestion. */
+ private dismissSuggestion() {
+ this.pendingSuggestion = null;
+ this.renderAll();
+ }
+
/** Highlight task nodes that have unfulfilled ports matching the given skill. */
private applySkillHighlights(skill: string) {
const groups = this.nodesLayer.querySelectorAll('.task-node');