Merge branch 'dev'
CI/CD / deploy (push) Successful in 3m0s
Details
CI/CD / deploy (push) Successful in 3m0s
Details
This commit is contained in:
commit
5f00991264
|
|
@ -13,6 +13,8 @@ import { getModuleApiBase } from '../shared/url-helpers';
|
|||
|
||||
const BUY_COLOR = '#10b981';
|
||||
const SELL_COLOR = '#f59e0b';
|
||||
const LP_COLOR_LEFT = '#10b981';
|
||||
const LP_COLOR_RIGHT = '#f59e0b';
|
||||
const MATCH_ARC_COLOR = '#60a5fa';
|
||||
const BG_COLOR = '#0f172a';
|
||||
const TEXT_COLOR = '#e2e8f0';
|
||||
|
|
@ -40,6 +42,32 @@ interface OrderIntent {
|
|||
status: string;
|
||||
}
|
||||
|
||||
interface LpPosition {
|
||||
id: string;
|
||||
creatorName: string;
|
||||
tokenId: string;
|
||||
fiatCurrency: string;
|
||||
tokenRemaining: number;
|
||||
fiatRemaining: number;
|
||||
spreadBps: number;
|
||||
feesEarnedToken: number;
|
||||
feesEarnedFiat: number;
|
||||
tradesMatched: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LpOrb {
|
||||
position: LpPosition;
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
phase: number;
|
||||
opacity: number;
|
||||
hoverT: number;
|
||||
}
|
||||
|
||||
interface Orb {
|
||||
intent: OrderIntent;
|
||||
x: number;
|
||||
|
|
@ -61,11 +89,14 @@ export class FolkExchangeNode extends FolkShape {
|
|||
private _canvas: HTMLCanvasElement | null = null;
|
||||
private _ctx: CanvasRenderingContext2D | null = null;
|
||||
private _orbs: Orb[] = [];
|
||||
private _lpOrbs: LpOrb[] = [];
|
||||
private _hovered: Orb | null = null;
|
||||
private _hoveredLp: LpOrb | null = null;
|
||||
private _animFrame = 0;
|
||||
private _pollTimer = 0;
|
||||
private _spaceSlug = 'demo';
|
||||
private _intents: OrderIntent[] = [];
|
||||
private _lpPositions: LpPosition[] = [];
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
|
@ -105,6 +136,9 @@ export class FolkExchangeNode extends FolkShape {
|
|||
<span style="font-size:0.7rem;color:${SELL_COLOR};display:flex;align-items:center;gap:4px">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${SELL_COLOR};display:inline-block"></span> Sell
|
||||
</span>
|
||||
<span style="font-size:0.7rem;color:#a78bfa;display:flex;align-items:center;gap:4px">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:linear-gradient(to right,${LP_COLOR_LEFT},${LP_COLOR_RIGHT});display:inline-block"></span> LP
|
||||
</span>
|
||||
<span style="font-size:0.7rem;color:${MATCH_ARC_COLOR};display:flex;align-items:center;gap:4px">
|
||||
<span style="width:8px;height:1px;background:${MATCH_ARC_COLOR};display:inline-block"></span> Matched
|
||||
</span>
|
||||
|
|
@ -119,7 +153,7 @@ export class FolkExchangeNode extends FolkShape {
|
|||
shadow.appendChild(wrapper);
|
||||
|
||||
this._canvas.addEventListener('mousemove', (e) => this._onMouseMove(e));
|
||||
this._canvas.addEventListener('mouseleave', () => { this._hovered = null; });
|
||||
this._canvas.addEventListener('mouseleave', () => { this._hovered = null; this._hoveredLp = null; });
|
||||
|
||||
this._resizeCanvas();
|
||||
new ResizeObserver(() => this._resizeCanvas()).observe(wrapper);
|
||||
|
|
@ -139,14 +173,23 @@ export class FolkExchangeNode extends FolkShape {
|
|||
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 [intentsRes, poolsRes] = await Promise.all([
|
||||
fetch(`${base}/api/exchange/intents`),
|
||||
fetch(`${base}/api/exchange/pools`),
|
||||
]);
|
||||
if (intentsRes.ok) {
|
||||
const data = await intentsRes.json() as { intents: OrderIntent[] };
|
||||
this._intents = data.intents || [];
|
||||
this._syncOrbs();
|
||||
}
|
||||
if (poolsRes.ok) {
|
||||
const data = await poolsRes.json() as { positions: LpPosition[] };
|
||||
this._lpPositions = data.positions || [];
|
||||
this._syncLpOrbs();
|
||||
}
|
||||
|
||||
const counter = this.shadowRoot?.querySelector('#intent-count');
|
||||
if (counter) counter.textContent = `${this._intents.length} intents`;
|
||||
if (counter) counter.textContent = `${this._intents.length} intents, ${this._lpPositions.length} pools`;
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +228,38 @@ export class FolkExchangeNode extends FolkShape {
|
|||
}
|
||||
}
|
||||
|
||||
private _syncLpOrbs() {
|
||||
const rect = this._canvas?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const existingIds = new Set(this._lpOrbs.map(o => o.position.id));
|
||||
const newIds = new Set(this._lpPositions.map(p => p.id));
|
||||
|
||||
// Remove stale
|
||||
this._lpOrbs = this._lpOrbs.filter(o => newIds.has(o.position.id));
|
||||
|
||||
// Add new LP orbs — centered on the divider
|
||||
for (const pos of this._lpPositions) {
|
||||
if (existingIds.has(pos.id)) continue;
|
||||
|
||||
const totalDepth = (pos.tokenRemaining / 1_000_000) + pos.fiatRemaining;
|
||||
const radius = Math.max(16, Math.min(36, 16 + Math.sqrt(totalDepth) * 1.5));
|
||||
|
||||
this._lpOrbs.push({
|
||||
position: pos,
|
||||
x: w * 0.5 + (Math.random() - 0.5) * 20,
|
||||
y: 60 + Math.random() * (h - 120),
|
||||
radius,
|
||||
vx: (Math.random() - 0.5) * 0.15,
|
||||
vy: (Math.random() - 0.5) * 0.15,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
opacity: 0,
|
||||
hoverT: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _onMouseMove(e: MouseEvent) {
|
||||
const rect = this._canvas?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
|
@ -192,14 +267,26 @@ export class FolkExchangeNode extends FolkShape {
|
|||
const my = e.clientY - rect.top;
|
||||
|
||||
this._hovered = null;
|
||||
for (const orb of this._orbs) {
|
||||
this._hoveredLp = null;
|
||||
|
||||
// Check LP orbs first (they're on top)
|
||||
for (const orb of this._lpOrbs) {
|
||||
const dx = mx - orb.x, dy = my - orb.y;
|
||||
if (dx * dx + dy * dy < orb.radius * orb.radius) {
|
||||
this._hovered = orb;
|
||||
this._hoveredLp = orb;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._canvas!.style.cursor = this._hovered ? 'pointer' : 'default';
|
||||
if (!this._hoveredLp) {
|
||||
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 || this._hoveredLp) ? 'pointer' : 'default';
|
||||
}
|
||||
|
||||
private _animate() {
|
||||
|
|
@ -240,6 +327,32 @@ export class FolkExchangeNode extends FolkShape {
|
|||
orb.hoverT += ((isH ? 1 : 0) - orb.hoverT) * 0.12;
|
||||
if (orb.opacity < 1) orb.opacity = Math.min(1, orb.opacity + 0.03);
|
||||
}
|
||||
|
||||
// Update LP orbs — constrained to straddle the divider
|
||||
for (const orb of this._lpOrbs) {
|
||||
orb.phase += 0.004;
|
||||
orb.vx += Math.sin(orb.phase) * 0.001;
|
||||
orb.vy += Math.cos(orb.phase * 0.7 + 1.5) * 0.001;
|
||||
orb.vx *= 0.99;
|
||||
orb.vy *= 0.99;
|
||||
orb.x += orb.vx;
|
||||
orb.y += orb.vy;
|
||||
|
||||
// Constrain to center (spanning divider)
|
||||
const minX = w * 0.5 - orb.radius * 1.5;
|
||||
const maxX = w * 0.5 + orb.radius * 1.5;
|
||||
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; }
|
||||
|
||||
const isH = this._hoveredLp === 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() {
|
||||
|
|
@ -328,6 +441,102 @@ export class FolkExchangeNode extends FolkShape {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
// Draw LP orbs (on the divider)
|
||||
for (const orb of this._lpOrbs) {
|
||||
if (orb.opacity < 0.01) continue;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = orb.opacity;
|
||||
|
||||
if (orb.hoverT > 0.05) {
|
||||
ctx.shadowColor = '#a78bfa';
|
||||
ctx.shadowBlur = 24 * orb.hoverT;
|
||||
}
|
||||
|
||||
const r = orb.radius * (1 + orb.hoverT * 0.15);
|
||||
|
||||
// Outer glow — dual gradient
|
||||
ctx.beginPath();
|
||||
ctx.arc(orb.x, orb.y, r, 0, Math.PI * 2);
|
||||
const outerGrad = ctx.createLinearGradient(orb.x - r, orb.y, orb.x + r, orb.y);
|
||||
outerGrad.addColorStop(0, LP_COLOR_LEFT + '20');
|
||||
outerGrad.addColorStop(1, LP_COLOR_RIGHT + '20');
|
||||
ctx.fillStyle = outerGrad;
|
||||
ctx.fill();
|
||||
|
||||
// Inner circle — dual-colored gradient (green left, amber right)
|
||||
ctx.beginPath();
|
||||
ctx.arc(orb.x, orb.y, r * 0.82, 0, Math.PI * 2);
|
||||
const innerGrad = ctx.createLinearGradient(orb.x - r, orb.y, orb.x + r, orb.y);
|
||||
innerGrad.addColorStop(0, LP_COLOR_LEFT);
|
||||
innerGrad.addColorStop(0.5, '#34d399');
|
||||
innerGrad.addColorStop(1, LP_COLOR_RIGHT);
|
||||
ctx.fillStyle = innerGrad;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Border gradient
|
||||
ctx.strokeStyle = '#a78bfa';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Token icon
|
||||
ctx.font = `${Math.max(10, r * 0.45)}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#fff';
|
||||
const icon = TOKEN_ICONS[orb.position.tokenId] || '$';
|
||||
ctx.fillText(icon, orb.x, orb.y);
|
||||
|
||||
// LP badge dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(orb.x + r * 0.65, orb.y - r * 0.65, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#8b5cf6';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#0f172a';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Hover tooltip — LP positions
|
||||
if (this._hoveredLp) {
|
||||
const orb = this._hoveredLp;
|
||||
const p = orb.position;
|
||||
const tokenDepth = (p.tokenRemaining / 1_000_000).toFixed(2);
|
||||
const fiatSymbol = p.fiatCurrency === 'USD' ? '$' : p.fiatCurrency === 'EUR' ? '€' : p.fiatCurrency === 'GBP' ? '£' : '';
|
||||
const earned = `${fiatSymbol}${p.feesEarnedFiat.toFixed(2)}`;
|
||||
const lines = [
|
||||
`${p.creatorName} (LP)`,
|
||||
`${p.tokenId}/${p.fiatCurrency}`,
|
||||
`Depth: ${tokenDepth} / ${fiatSymbol}${p.fiatRemaining.toFixed(2)}`,
|
||||
`Spread: ${p.spreadBps}bps`,
|
||||
`Earned: ${earned} | ${p.tradesMatched} trades`,
|
||||
];
|
||||
|
||||
const tooltipX = orb.x + orb.radius + 12;
|
||||
const tooltipY = Math.max(50, Math.min(h - 100, orb.y - 40));
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#1e293bee';
|
||||
const tw = 180;
|
||||
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.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
for (let l = 0; l < lines.length; l++) {
|
||||
ctx.fillStyle = l === 0 ? '#a78bfa' : 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();
|
||||
}
|
||||
|
||||
// Hover tooltip
|
||||
if (this._hovered) {
|
||||
const orb = this._hovered;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
* top_trader — ≥ $10k equivalent volume
|
||||
*/
|
||||
|
||||
import type { ExchangeReputationRecord, ExchangeReputationDoc, ExchangeTradesDoc } from './schemas';
|
||||
import type { ExchangeReputationRecord, ExchangeReputationDoc, ExchangeTradesDoc, LiquidityPosition } from './schemas';
|
||||
|
||||
export const DEFAULT_REPUTATION: ExchangeReputationRecord = {
|
||||
did: '',
|
||||
|
|
@ -55,10 +55,12 @@ export function calculateScore(rec: ExchangeReputationRecord): number {
|
|||
|
||||
/**
|
||||
* Compute badges based on reputation stats.
|
||||
* LP badge awarded for standing orders OR active LP positions.
|
||||
*/
|
||||
export function computeBadges(
|
||||
rec: ExchangeReputationRecord,
|
||||
hasStandingOrders: boolean,
|
||||
hasActiveLpPositions: boolean = false,
|
||||
): string[] {
|
||||
const badges: string[] = [];
|
||||
|
||||
|
|
@ -66,7 +68,7 @@ export function computeBadges(
|
|||
badges.push('verified_seller');
|
||||
}
|
||||
|
||||
if (hasStandingOrders) {
|
||||
if (hasStandingOrders || hasActiveLpPositions) {
|
||||
badges.push('liquidity_provider');
|
||||
}
|
||||
|
||||
|
|
@ -122,3 +124,16 @@ export function hasStandingOrders(
|
|||
i => i.creatorDid === did && i.isStandingOrder && i.status === 'active',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a DID has active liquidity pool positions.
|
||||
*/
|
||||
export function hasActiveLpPositions(
|
||||
did: string,
|
||||
poolsDoc: { positions: Record<string, LiquidityPosition> } | null | undefined,
|
||||
): boolean {
|
||||
if (!poolsDoc) return false;
|
||||
return Object.values(poolsDoc.positions).some(
|
||||
p => p.creatorDid === did && p.status === 'active',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ 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,
|
||||
exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, exchangePoolsDocId,
|
||||
exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema, exchangePoolsSchema,
|
||||
} from './schemas';
|
||||
import type {
|
||||
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc,
|
||||
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, ExchangePoolsDoc,
|
||||
ExchangeIntent, ExchangeTrade, ExchangeSide, TokenId, FiatCurrency, RateType,
|
||||
LiquidityPosition,
|
||||
} from './schemas';
|
||||
import { solveExchange } from './exchange-solver';
|
||||
import { lockEscrow, releaseEscrow, reverseEscrow, resolveDispute, sweepTimeouts } from './exchange-settlement';
|
||||
|
|
@ -80,6 +81,21 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) {
|
|||
return doc;
|
||||
}
|
||||
|
||||
function ensurePoolsDoc(space: string): ExchangePoolsDoc {
|
||||
const syncServer = ss();
|
||||
const docId = exchangePoolsDocId(space);
|
||||
let doc = syncServer.getDoc<ExchangePoolsDoc>(docId);
|
||||
if (!doc) {
|
||||
doc = Automerge.change(Automerge.init<ExchangePoolsDoc>(), 'init exchange pools', (d) => {
|
||||
const init = exchangePoolsSchema.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) => {
|
||||
|
|
@ -487,6 +503,171 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) {
|
|||
return c.json(updated.trades[id]);
|
||||
});
|
||||
|
||||
// ── POST /api/exchange/pool — Add Liquidity ──
|
||||
|
||||
routes.post('/api/exchange/pool', 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 { tokenId, fiatCurrency, tokenAmount, fiatAmount, spreadBps, paymentMethods } = body;
|
||||
|
||||
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 (typeof tokenAmount !== 'number' || tokenAmount <= 0) return c.json({ error: 'tokenAmount must be positive' }, 400);
|
||||
if (typeof fiatAmount !== 'number' || fiatAmount <= 0) return c.json({ error: 'fiatAmount must be positive' }, 400);
|
||||
if (typeof spreadBps !== 'number' || spreadBps < 0 || spreadBps > 1000) return c.json({ error: 'spreadBps must be 0-1000' }, 400);
|
||||
if (!paymentMethods?.length) return c.json({ error: 'At least one payment method required' }, 400);
|
||||
|
||||
const positionId = crypto.randomUUID();
|
||||
const buyIntentId = crypto.randomUUID();
|
||||
const sellIntentId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const creatorDid = claims.did as string;
|
||||
const creatorName = claims.username as string || 'Unknown';
|
||||
|
||||
// Create paired standing orders — buy at market-spreadBps, sell at market+spreadBps
|
||||
ensureIntentsDoc(space);
|
||||
const buyIntent: ExchangeIntent = {
|
||||
id: buyIntentId, creatorDid, creatorName,
|
||||
side: 'buy', tokenId, fiatCurrency,
|
||||
tokenAmountMin: 1_000_000, // 1 token minimum per fill
|
||||
tokenAmountMax: tokenAmount,
|
||||
rateType: 'market_plus_bps', rateMarketBps: spreadBps,
|
||||
paymentMethods, isStandingOrder: true, autoAccept: true,
|
||||
allowInstitutionalFallback: false, status: 'active',
|
||||
createdAt: now,
|
||||
};
|
||||
const sellIntent: ExchangeIntent = {
|
||||
id: sellIntentId, creatorDid, creatorName,
|
||||
side: 'sell', tokenId, fiatCurrency,
|
||||
tokenAmountMin: 1_000_000,
|
||||
tokenAmountMax: tokenAmount,
|
||||
rateType: 'market_plus_bps', rateMarketBps: spreadBps,
|
||||
paymentMethods, isStandingOrder: true, autoAccept: true,
|
||||
allowInstitutionalFallback: false, status: 'active',
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
ss().changeDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space), 'create LP paired intents', (d) => {
|
||||
d.intents[buyIntentId] = buyIntent as any;
|
||||
d.intents[sellIntentId] = sellIntent as any;
|
||||
});
|
||||
|
||||
// Create the liquidity position
|
||||
const position: LiquidityPosition = {
|
||||
id: positionId, creatorDid, creatorName,
|
||||
tokenId, fiatCurrency,
|
||||
tokenCommitted: tokenAmount, tokenRemaining: tokenAmount,
|
||||
fiatCommitted: fiatAmount, fiatRemaining: fiatAmount,
|
||||
spreadBps, paymentMethods,
|
||||
buyIntentId, sellIntentId,
|
||||
feesEarnedToken: 0, feesEarnedFiat: 0, tradesMatched: 0,
|
||||
status: 'active', createdAt: now, updatedAt: now,
|
||||
};
|
||||
|
||||
ensurePoolsDoc(space);
|
||||
ss().changeDoc<ExchangePoolsDoc>(exchangePoolsDocId(space), 'create liquidity position', (d) => {
|
||||
d.positions[positionId] = position as any;
|
||||
});
|
||||
|
||||
const doc = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
|
||||
return c.json(doc.positions[positionId], 201);
|
||||
});
|
||||
|
||||
// ── GET /api/exchange/pools — List all active positions ──
|
||||
|
||||
routes.get('/api/exchange/pools', (c) => {
|
||||
const space = c.req.param('space') || 'demo';
|
||||
ensurePoolsDoc(space);
|
||||
const doc = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
|
||||
const positions = Object.values(doc.positions).filter(p => p.status === 'active');
|
||||
return c.json({ positions });
|
||||
});
|
||||
|
||||
// ── GET /api/exchange/pools/mine — My positions ──
|
||||
|
||||
routes.get('/api/exchange/pools/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';
|
||||
ensurePoolsDoc(space);
|
||||
const doc = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
|
||||
const positions = Object.values(doc.positions).filter(p => p.creatorDid === claims.did);
|
||||
return c.json({ positions });
|
||||
});
|
||||
|
||||
// ── PATCH /api/exchange/pool/:id — Update or withdraw ──
|
||||
|
||||
routes.patch('/api/exchange/pool/: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();
|
||||
|
||||
ensurePoolsDoc(space);
|
||||
const doc = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
|
||||
const position = doc.positions[id];
|
||||
if (!position) return c.json({ error: 'Position not found' }, 404);
|
||||
if (position.creatorDid !== claims.did) return c.json({ error: 'Not your position' }, 403);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Handle status changes
|
||||
if (body.status === 'paused' || body.status === 'withdrawn') {
|
||||
// Cancel linked intents
|
||||
ensureIntentsDoc(space);
|
||||
ss().changeDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space), `LP ${body.status}: cancel linked intents`, (d) => {
|
||||
if (d.intents[position.buyIntentId]) d.intents[position.buyIntentId].status = 'cancelled' as any;
|
||||
if (d.intents[position.sellIntentId]) d.intents[position.sellIntentId].status = 'cancelled' as any;
|
||||
});
|
||||
}
|
||||
|
||||
ss().changeDoc<ExchangePoolsDoc>(exchangePoolsDocId(space), 'update liquidity position', (d) => {
|
||||
const p = d.positions[id];
|
||||
if (body.status) p.status = body.status as any;
|
||||
if (body.spreadBps != null) p.spreadBps = body.spreadBps as any;
|
||||
if (body.paymentMethods) p.paymentMethods = body.paymentMethods as any;
|
||||
if (body.tokenCommitted != null) {
|
||||
const delta = body.tokenCommitted - p.tokenCommitted;
|
||||
p.tokenCommitted = body.tokenCommitted as any;
|
||||
p.tokenRemaining = Math.max(0, p.tokenRemaining + delta) as any;
|
||||
}
|
||||
if (body.fiatCommitted != null) {
|
||||
const delta = body.fiatCommitted - p.fiatCommitted;
|
||||
p.fiatCommitted = body.fiatCommitted as any;
|
||||
p.fiatRemaining = Math.max(0, p.fiatRemaining + delta) as any;
|
||||
}
|
||||
p.updatedAt = now as any;
|
||||
});
|
||||
|
||||
// If updating spread or payment methods, also update linked intents
|
||||
if ((body.spreadBps != null || body.paymentMethods) && body.status !== 'paused' && body.status !== 'withdrawn') {
|
||||
ensureIntentsDoc(space);
|
||||
ss().changeDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space), 'update LP linked intents', (d) => {
|
||||
for (const intentId of [position.buyIntentId, position.sellIntentId]) {
|
||||
const intent = d.intents[intentId];
|
||||
if (!intent) continue;
|
||||
if (body.spreadBps != null) intent.rateMarketBps = body.spreadBps as any;
|
||||
if (body.paymentMethods) intent.paymentMethods = body.paymentMethods as any;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const updated = ss().getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space))!;
|
||||
return c.json(updated.positions[id]);
|
||||
});
|
||||
|
||||
// ── GET /api/exchange/reputation/:did — Reputation lookup ──
|
||||
|
||||
routes.get('/api/exchange/reputation/:did', (c) => {
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ import {
|
|||
mintTokens, getTokenDoc, getBalance,
|
||||
} from '../../server/token-service';
|
||||
import {
|
||||
exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId,
|
||||
exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, exchangePoolsDocId,
|
||||
} from './schemas';
|
||||
import type {
|
||||
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc,
|
||||
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, ExchangePoolsDoc,
|
||||
ExchangeTrade, TradeStatus,
|
||||
} from './schemas';
|
||||
import { updateReputationAfterTrade, calculateScore, computeBadges, hasStandingOrders } from './exchange-reputation';
|
||||
|
|
@ -146,6 +146,9 @@ export function releaseEscrow(
|
|||
: 0;
|
||||
updateReputationForTrade(trade, confirmTime, syncServer, space);
|
||||
|
||||
// Track LP fee accrual if trade matched an LP position
|
||||
updateLpPositionAfterTrade(trade, syncServer, space);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +376,55 @@ function updateReputationForTrade(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* After a trade completes, check if either intent belongs to an LP position.
|
||||
* If so, update fee earnings and remaining amounts on the position.
|
||||
*/
|
||||
function updateLpPositionAfterTrade(
|
||||
trade: ExchangeTrade,
|
||||
syncServer: SyncServer,
|
||||
space: string,
|
||||
): void {
|
||||
const poolsDocId = exchangePoolsDocId(space);
|
||||
const poolsDoc = syncServer.getDoc<ExchangePoolsDoc>(poolsDocId);
|
||||
if (!poolsDoc) return;
|
||||
|
||||
// Find position that owns either the buy or sell intent
|
||||
const position = Object.values(poolsDoc.positions).find(
|
||||
p => p.buyIntentId === trade.buyIntentId || p.sellIntentId === trade.sellIntentId,
|
||||
);
|
||||
if (!position || position.status !== 'active') return;
|
||||
|
||||
syncServer.changeDoc<ExchangePoolsDoc>(poolsDocId, 'LP fee accrual after trade', (d) => {
|
||||
const p = d.positions[position.id];
|
||||
if (!p) return;
|
||||
|
||||
p.tradesMatched = (p.tradesMatched + 1) as any;
|
||||
p.updatedAt = Date.now() as any;
|
||||
|
||||
// Determine which side was filled
|
||||
if (position.sellIntentId === trade.sellIntentId) {
|
||||
// LP sold tokens → decrement tokenRemaining, earn fiat fee
|
||||
p.tokenRemaining = Math.max(0, p.tokenRemaining - trade.tokenAmount) as any;
|
||||
// Fee ≈ spread portion of the fiat amount: (spreadBps/10000) × fiatAmount
|
||||
const fiatFee = (p.spreadBps / 10000) * trade.fiatAmount;
|
||||
p.feesEarnedFiat = (p.feesEarnedFiat + fiatFee) as any;
|
||||
}
|
||||
if (position.buyIntentId === trade.buyIntentId) {
|
||||
// LP bought tokens → decrement fiatRemaining, earn token fee
|
||||
p.fiatRemaining = Math.max(0, p.fiatRemaining - trade.fiatAmount) as any;
|
||||
// Fee ≈ spread portion of the token amount: (spreadBps/10000) × tokenAmount
|
||||
const tokenFee = Math.round((p.spreadBps / 10000) * trade.tokenAmount);
|
||||
p.feesEarnedToken = (p.feesEarnedToken + tokenFee) as any;
|
||||
}
|
||||
|
||||
// If either side depleted, pause the position
|
||||
if (p.tokenRemaining <= 0 || p.fiatRemaining <= 0) {
|
||||
p.status = 'paused' as any;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateDisputeLoser(
|
||||
loserDid: string,
|
||||
syncServer: SyncServer,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,65 @@ export function renderLanding(): string {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Provide Liquidity -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<div style="text-align:center;margin-bottom:2rem">
|
||||
<span class="rl-tagline" style="color:#8b5cf6;background:rgba(139,92,246,0.1);border-color:rgba(139,92,246,0.2)">
|
||||
Passive Income
|
||||
</span>
|
||||
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#8b5cf6,#10b981);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Earn the Spread
|
||||
</h2>
|
||||
<p style="font-size:1.05rem;color:#94a3b8;max-width:640px;margin:0.5rem auto 0">
|
||||
Provide liquidity to popular pairs and earn fees on every trade.
|
||||
Set it and forget it — your positions work 24/7.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-grid-3">
|
||||
<div class="rl-card" style="border:2px solid rgba(139,92,246,0.35);background:linear-gradient(to bottom right,rgba(139,92,246,0.08),rgba(139,92,246,0.03))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:#8b5cf6;display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:white;font-weight:700;font-size:0.9rem">1</span>
|
||||
</div>
|
||||
<h3 style="color:#a78bfa;font-size:1.05rem;margin-bottom:0">Pick a Pair</h3>
|
||||
</div>
|
||||
<p>
|
||||
Choose a token/fiat pair like cUSDC/EUR or cUSDC/USD. Popular pairs get more
|
||||
trade volume and more fee income.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card" style="border:2px solid rgba(16,185,129,0.35);background:linear-gradient(to bottom right,rgba(16,185,129,0.06),rgba(139,92,246,0.03))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:linear-gradient(to bottom right,#8b5cf6,#10b981);display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:white;font-weight:700;font-size:0.9rem">2</span>
|
||||
</div>
|
||||
<h3 style="color:#34d399;font-size:1.05rem;margin-bottom:0">Set Amount & Spread</h3>
|
||||
</div>
|
||||
<p>
|
||||
Commit tokens and fiat. Set your spread in basis points —
|
||||
this is your fee on each trade. Both buy and sell orders are created automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card" style="border:2px solid rgba(245,158,11,0.35);background:linear-gradient(to bottom right,rgba(245,158,11,0.06),rgba(16,185,129,0.03))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:linear-gradient(to bottom right,#10b981,#f59e0b);display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:white;font-weight:700;font-size:0.9rem">3</span>
|
||||
</div>
|
||||
<h3 style="color:#fbbf24;font-size:1.05rem;margin-bottom:0">Earn Fees</h3>
|
||||
</div>
|
||||
<p>
|
||||
Trades are auto-accepted and settled. You earn the spread on every fill.
|
||||
Track your earnings, pause, or withdraw any time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ import type { RSpaceModule } from '../../shared/module';
|
|||
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||
import { renderLanding } from './landing';
|
||||
import {
|
||||
exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema,
|
||||
exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId,
|
||||
exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema, exchangePoolsSchema,
|
||||
exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, exchangePoolsDocId,
|
||||
} from './schemas';
|
||||
import type {
|
||||
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc,
|
||||
ExchangeIntent, ExchangeTrade, ExchangeReputationRecord,
|
||||
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, ExchangePoolsDoc,
|
||||
ExchangeIntent, ExchangeTrade, ExchangeReputationRecord, LiquidityPosition,
|
||||
} from './schemas';
|
||||
import { createExchangeRoutes, startSolverCron } from './exchange-routes';
|
||||
|
||||
|
|
@ -81,6 +81,20 @@ function ensureReputationDoc(space: string): ExchangeReputationDoc {
|
|||
return doc;
|
||||
}
|
||||
|
||||
function ensurePoolsDoc(space: string): ExchangePoolsDoc {
|
||||
const docId = exchangePoolsDocId(space);
|
||||
let doc = _syncServer!.getDoc<ExchangePoolsDoc>(docId);
|
||||
if (!doc) {
|
||||
doc = Automerge.change(Automerge.init<ExchangePoolsDoc>(), 'init exchange pools', (d) => {
|
||||
const init = exchangePoolsSchema.init();
|
||||
Object.assign(d, init);
|
||||
d.meta.spaceSlug = space;
|
||||
});
|
||||
_syncServer!.setDoc(docId, doc);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── Demo seeding ──
|
||||
|
||||
const DEMO_DIDS = {
|
||||
|
|
@ -195,6 +209,27 @@ const DEMO_TRADES: Omit<ExchangeTrade, 'id' | 'createdAt'>[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const DEMO_LP_POSITIONS: Omit<LiquidityPosition, 'id' | 'createdAt' | 'updatedAt' | 'buyIntentId' | 'sellIntentId'>[] = [
|
||||
{
|
||||
creatorDid: DEMO_DIDS.maya, creatorName: 'Maya',
|
||||
tokenId: 'cusdc', fiatCurrency: 'USD',
|
||||
tokenCommitted: 500_000_000, tokenRemaining: 488_000_000,
|
||||
fiatCommitted: 490, fiatRemaining: 477.50,
|
||||
spreadBps: 50, paymentMethods: ['Revolut', 'Cash'],
|
||||
feesEarnedToken: 62_500, feesEarnedFiat: 12.50,
|
||||
tradesMatched: 8, status: 'active',
|
||||
},
|
||||
{
|
||||
creatorDid: DEMO_DIDS.bob, creatorName: 'Bob',
|
||||
tokenId: 'cusdc', fiatCurrency: 'EUR',
|
||||
tokenCommitted: 300_000_000, tokenRemaining: 285_000_000,
|
||||
fiatCommitted: 294, fiatRemaining: 285.80,
|
||||
spreadBps: 30, paymentMethods: ['SEPA'],
|
||||
feesEarnedToken: 45_000, feesEarnedFiat: 8.20,
|
||||
tradesMatched: 15, status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
function seedDemoIfEmpty(space: string = 'demo') {
|
||||
if (!_syncServer) return;
|
||||
const existing = _syncServer.getDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space));
|
||||
|
|
@ -228,7 +263,23 @@ function seedDemoIfEmpty(space: string = 'demo') {
|
|||
}
|
||||
});
|
||||
|
||||
console.log(`[rExchange] Demo data seeded for "${space}": ${DEMO_INTENTS.length} intents, ${DEMO_TRADES.length} trades, ${DEMO_REPUTATION.length} reputation records`);
|
||||
// Seed LP positions
|
||||
ensurePoolsDoc(space);
|
||||
_syncServer.changeDoc<ExchangePoolsDoc>(exchangePoolsDocId(space), 'seed LP positions', (d) => {
|
||||
for (const lp of DEMO_LP_POSITIONS) {
|
||||
const id = crypto.randomUUID();
|
||||
const buyIntentId = crypto.randomUUID();
|
||||
const sellIntentId = crypto.randomUUID();
|
||||
d.positions[id] = {
|
||||
id, ...lp,
|
||||
buyIntentId, sellIntentId,
|
||||
createdAt: now - 86400_000 * 7,
|
||||
updatedAt: now - 3600_000,
|
||||
} as any;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[rExchange] Demo data seeded for "${space}": ${DEMO_INTENTS.length} intents, ${DEMO_TRADES.length} trades, ${DEMO_REPUTATION.length} reputation records, ${DEMO_LP_POSITIONS.length} LP positions`);
|
||||
}
|
||||
|
||||
// ── Server-rendered order book page ──
|
||||
|
|
@ -237,6 +288,7 @@ function renderOrderBook(space: string): string {
|
|||
const intentsDoc = _syncServer?.getDoc<ExchangeIntentsDoc>(exchangeIntentsDocId(space));
|
||||
const tradesDoc = _syncServer?.getDoc<ExchangeTradesDoc>(exchangeTradesDocId(space));
|
||||
const repDoc = _syncServer?.getDoc<ExchangeReputationDoc>(exchangeReputationDocId(space));
|
||||
const poolsDoc = _syncServer?.getDoc<ExchangePoolsDoc>(exchangePoolsDocId(space));
|
||||
|
||||
const intents = intentsDoc ? Object.values(intentsDoc.intents) : [];
|
||||
const trades = tradesDoc ? Object.values(tradesDoc.trades) : [];
|
||||
|
|
@ -245,6 +297,7 @@ function renderOrderBook(space: string): string {
|
|||
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');
|
||||
const activePositions = poolsDoc ? Object.values(poolsDoc.positions).filter(p => p.status === 'active') : [];
|
||||
|
||||
function fmtAmount(base: number) {
|
||||
return (base / 1_000_000).toFixed(2);
|
||||
|
|
@ -299,6 +352,24 @@ function renderOrderBook(space: string): string {
|
|||
</tr>`;
|
||||
}
|
||||
|
||||
function poolRow(p: LiquidityPosition) {
|
||||
const icon = p.tokenId === 'cusdc' ? '💵' : p.tokenId === 'myco' ? '🌱' : '🎮';
|
||||
const tokenDepth = fmtAmount(p.tokenRemaining);
|
||||
const fiatDepth = p.fiatRemaining.toFixed(2);
|
||||
const fiatSymbol = p.fiatCurrency === 'USD' ? '$' : p.fiatCurrency === 'EUR' ? '€' : p.fiatCurrency === 'GBP' ? '£' : '';
|
||||
const earned = p.fiatCurrency === 'USD' ? `$${p.feesEarnedFiat.toFixed(2)}`
|
||||
: p.fiatCurrency === 'EUR' ? `€${p.feesEarnedFiat.toFixed(2)}`
|
||||
: `${p.feesEarnedFiat.toFixed(2)} ${p.fiatCurrency}`;
|
||||
return `<tr style="border-bottom:1px solid #1e293b">
|
||||
<td style="padding:8px">${p.creatorName} ${repBadge(p.creatorDid)}</td>
|
||||
<td style="padding:8px">${icon} ${p.tokenId}/${p.fiatCurrency}</td>
|
||||
<td style="padding:8px">${tokenDepth} / ${fiatSymbol}${fiatDepth}</td>
|
||||
<td style="padding:8px">${p.spreadBps}bps</td>
|
||||
<td style="padding:8px;color:#22c55e">${earned}</td>
|
||||
<td style="padding:8px">${p.tradesMatched}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="max-width:960px;margin:0 auto;padding:24px 16px;color:#e2e8f0;font-family:system-ui,sans-serif">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
|
||||
|
|
@ -311,6 +382,7 @@ function renderOrderBook(space: string): string {
|
|||
<div style="display:flex;gap:12px;font-size:0.8rem">
|
||||
<span style="color:#10b981">${buyIntents.length} buys</span>
|
||||
<span style="color:#f59e0b">${sellIntents.length} sells</span>
|
||||
<span style="color:#8b5cf6">${activePositions.length} pools</span>
|
||||
<span style="color:#3b82f6">${activeTrades.length} active</span>
|
||||
<span style="color:#22c55e">${completedTrades.length} settled</span>
|
||||
</div>
|
||||
|
|
@ -335,6 +407,25 @@ function renderOrderBook(space: string): string {
|
|||
</table>`}
|
||||
</div>
|
||||
|
||||
<!-- Liquidity Pools -->
|
||||
${activePositions.length > 0 ? `
|
||||
<div style="background:#0f172a;border:1px solid #1e293b;border-radius:12px;padding:16px;margin-bottom:24px">
|
||||
<h2 style="font-size:1rem;font-weight:600;margin:0 0 12px;color:#94a3b8;display:flex;align-items:center;gap:8px">
|
||||
<span style="background:linear-gradient(to right,#10b981,#f59e0b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">💧 Liquidity Pools</span>
|
||||
</h2>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:0.85rem">
|
||||
<thead><tr style="border-bottom:2px solid #1e293b;color:#64748b;font-size:0.75rem;text-transform:uppercase">
|
||||
<th style="padding:8px;text-align:left">Provider</th>
|
||||
<th style="padding:8px;text-align:left">Pair</th>
|
||||
<th style="padding:8px;text-align:left">Depth</th>
|
||||
<th style="padding:8px;text-align:left">Spread</th>
|
||||
<th style="padding:8px;text-align:left">Earned</th>
|
||||
<th style="padding:8px;text-align:left">Trades</th>
|
||||
</tr></thead>
|
||||
<tbody>${activePositions.map(poolRow).join('')}</tbody>
|
||||
</table>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Active Trades -->
|
||||
${activeTrades.length > 0 ? `
|
||||
<div style="background:#0f172a;border:1px solid #1e293b;border-radius:12px;padding:16px;margin-bottom:24px">
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, LiquidityPosition>;
|
||||
}
|
||||
|
||||
// ── 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<ExchangeIntentsDoc> = {
|
||||
|
|
@ -203,3 +251,19 @@ export const exchangeReputationSchema: DocSchema<ExchangeReputationDoc> = {
|
|||
records: {},
|
||||
}),
|
||||
};
|
||||
|
||||
export const exchangePoolsSchema: DocSchema<ExchangePoolsDoc> = {
|
||||
module: 'rexchange',
|
||||
collection: 'pools',
|
||||
version: 1,
|
||||
init: (): ExchangePoolsDoc => ({
|
||||
meta: {
|
||||
module: 'rexchange',
|
||||
collection: 'pools',
|
||||
version: 1,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
positions: {},
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ const EXEC_STEPS: Record<string, { title: string; desc: string; detail: string;
|
|||
};
|
||||
|
||||
const NODE_W = 90, NODE_H = 104;
|
||||
const TASK_W = 220, TASK_H_BASE = 52, TASK_ROW = 26;
|
||||
const TASK_W = 220, TASK_H_BASE = 60, TASK_ROW = 26;
|
||||
const GAS_TANK_Y = 30, GAS_TANK_H = 12, GAS_TANK_PAD = 12;
|
||||
const EXEC_BTN_H = 28;
|
||||
const PORT_R = 5.5;
|
||||
const HEX_R = 52;
|
||||
|
|
@ -300,6 +301,9 @@ class FolkTimebankApp extends HTMLElement {
|
|||
private sidebarDragData: { type: string; id: string } | null = null;
|
||||
private sidebarGhost: HTMLElement | null = null;
|
||||
|
||||
// Mycelial suggestion state (orb dropped on canvas, nearest task found)
|
||||
private pendingSuggestion: { fromId: string; toNode: WeaveNode; skill: string; hours: number; commitX: number; commitY: number } | null = null;
|
||||
|
||||
// Data
|
||||
private commitments: Commitment[] = [];
|
||||
private tasks: TaskData[] = [];
|
||||
|
|
@ -1249,6 +1253,66 @@ class FolkTimebankApp extends HTMLElement {
|
|||
label.textContent = conn.hours + 'h ' + (isCommitted ? '\u2713' : '\u23f3');
|
||||
this.connectionsLayer.appendChild(label);
|
||||
});
|
||||
|
||||
// ── Mycelial suggestion preview ──
|
||||
if (this.pendingSuggestion) {
|
||||
const s = this.pendingSuggestion;
|
||||
const fromNode = this.weaveNodes.find(n => 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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue