diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index b81e691..cd3e37b 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -1538,6 +1538,172 @@ class FolkWalletViewer extends HTMLElement { color: #e0e0e0; } .local-tokens-section table tr:last-child td { border-bottom: none; } + + /* ── Payment Actions (Buy/Swap/Withdraw) ── */ + .payment-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-top: 20px; + } + .action-card { + background: var(--rs-surface, #1a1a2e); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; + padding: 20px; + transition: border-color 0.2s; + } + .action-card:hover { border-color: rgba(255,255,255,0.15); } + .action-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + .action-header h4 { + margin: 0; + font-size: 1rem; + color: #e0e0e0; + flex: 1; + } + .action-icon { font-size: 1.3em; } + .action-badge { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 20px; + background: rgba(39,117,202,0.15); + color: #6bb5ff; + border: 1px solid rgba(39,117,202,0.3); + } + .action-badge.curve { + background: rgba(34,197,94,0.12); + color: #4ade80; + border-color: rgba(34,197,94,0.3); + } + .action-desc { + font-size: 0.82rem; + color: #999; + margin: 0 0 12px; + line-height: 1.4; + } + .provider-hints { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; + } + .provider-tag { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 12px; + background: rgba(255,255,255,0.04); + color: #aaa; + border: 1px solid rgba(255,255,255,0.08); + } + .provider-tag.eu { color: #6bb5ff; border-color: rgba(39,117,202,0.25); } + .provider-tag.us { color: #a78bfa; border-color: rgba(167,139,250,0.25); } + .provider-tag.small { color: #fbbf24; border-color: rgba(251,191,36,0.25); } + .swap-balances { + display: flex; + gap: 16px; + font-size: 0.82rem; + color: #bbb; + margin-bottom: 12px; + } + .swap-balances strong { color: #e0e0e0; } + .swap-tabs { + display: flex; + gap: 4px; + margin-bottom: 12px; + background: rgba(255,255,255,0.04); + border-radius: 8px; + padding: 3px; + } + .swap-tab { + flex: 1; + padding: 6px; + font-size: 0.82rem; + border: none; + border-radius: 6px; + background: transparent; + color: #999; + cursor: pointer; + transition: all 0.2s; + } + .swap-tab.active { + background: rgba(34,197,94,0.15); + color: #4ade80; + } + .swap-quote { + font-size: 0.82rem; + color: #bbb; + padding: 8px; + margin-bottom: 8px; + background: rgba(255,255,255,0.02); + border-radius: 6px; + min-height: 20px; + } + .action-form { + margin-top: 12px; + } + .form-row { + margin-bottom: 10px; + } + .form-row label { + display: block; + font-size: 0.75rem; + color: #999; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .action-input { + width: 100%; + padding: 8px 10px; + font-size: 0.88rem; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + background: rgba(0,0,0,0.2); + color: #e0e0e0; + box-sizing: border-box; + } + .action-input:focus { + outline: none; + border-color: var(--rs-accent, #14b8a6); + } + .action-btn { + width: 100%; + padding: 10px; + font-size: 0.88rem; + font-weight: 600; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + } + .action-btn.primary { + background: var(--rs-accent, #14b8a6); + color: #000; + } + .action-btn.primary:hover { opacity: 0.9; } + .action-btn.outline { + background: transparent; + border: 1px solid rgba(255,255,255,0.15); + color: #ccc; + } + .action-btn.outline:hover { + background: rgba(255,255,255,0.05); + border-color: rgba(255,255,255,0.25); + } + .action-status { + font-size: 0.82rem; + margin-top: 8px; + min-height: 20px; + } + .action-status.success { color: #4ade80; } + .action-status.error { color: #f87171; } + .action-status.loading { color: #fbbf24; } + .link-error { color: var(--rs-error); font-size: 12px; margin-top: 8px; padding: 6px 10px; background: rgba(239,83,80,0.08); border-radius: 6px; @@ -2510,6 +2676,116 @@ class FolkWalletViewer extends HTMLElement { `; } + private renderPaymentActions(): string { + if (!this.isAuthenticated) return ""; + + // Get cUSDC and $MYCO balances from CRDT + const cusdcBal = this.crdtBalances.find(b => b.symbol === 'cUSDC'); + const mycoBal = this.crdtBalances.find(b => b.symbol === '$MYCO'); + const cusdcFormatted = cusdcBal ? (cusdcBal.balance / Math.pow(10, cusdcBal.decimals)).toFixed(2) : '0.00'; + const mycoFormatted = mycoBal ? (mycoBal.balance / Math.pow(10, mycoBal.decimals)).toFixed(2) : '0.00'; + + return ` +
+ +
+
+ 💵 +

Buy cUSDC

+ Fiat → CRDT +
+

Deposit fiat via bank transfer or card. Auto-routed to cheapest provider.

+
+ EU: Mollie (SEPA/iDEAL) + US: Stripe (ACH/Card) + < $50: Mt Pelerin (no KYC) +
+ + +
+ + +
+
+ 🌱 +

$MYCO Swap

+ Bonding Curve +
+

Swap cUSDC ↔ $MYCO on the CRDT bonding curve. Price rises with supply.

+
+ cUSDC: ${cusdcFormatted} + $MYCO: ${mycoFormatted} +
+ + +
+ + +
+
+ 🏦 +

Withdraw

+ CRDT → Fiat +
+

Convert cUSDC back to fiat. Routed via SEPA (EU) or ACH (US).

+ + +
+
`; + } + private renderDashboard(): string { if (!this.hasData()) return ""; @@ -2569,7 +2845,7 @@ class FolkWalletViewer extends HTMLElement { ${this.renderViewTabs()} ${this.activeView === "balances" - ? this.renderBalanceTable() + ? this.renderBalanceTable() + this.renderPaymentActions() : this.activeView === "yield" ? this.renderYieldTab() : `
@@ -2786,6 +3062,236 @@ class FolkWalletViewer extends HTMLElement { }); } + private _swapAction: 'buy' | 'sell' = 'buy'; + + private attachPaymentListeners() { + // Toggle form visibility + const toggleBtn = (toggleId: string, formId: string) => { + const btn = this.shadow.querySelector(`#${toggleId}`); + const form = this.shadow.querySelector(`#${formId}`) as HTMLElement; + if (btn && form) { + btn.addEventListener('click', () => { + const visible = form.style.display !== 'none'; + form.style.display = visible ? 'none' : 'block'; + (btn as HTMLElement).style.display = visible ? 'block' : 'none'; + }); + } + }; + + toggleBtn('btn-toggle-buy', 'buy-cusdc-form'); + toggleBtn('btn-toggle-swap', 'myco-swap-form'); + toggleBtn('btn-toggle-withdraw', 'withdraw-form'); + + // Buy cUSDC + this.shadow.querySelector('#btn-buy-cusdc')?.addEventListener('click', () => this.handleBuyCUSDC()); + + // $MYCO Swap tabs + this.shadow.querySelectorAll('.swap-tab').forEach(tab => { + tab.addEventListener('click', () => { + this._swapAction = (tab as HTMLElement).dataset.swap as 'buy' | 'sell'; + this.shadow.querySelectorAll('.swap-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + const label = this.shadow.querySelector('#swap-input-label'); + if (label) label.textContent = this._swapAction === 'buy' ? 'cUSDC Amount' : '$MYCO Amount'; + this.updateSwapQuote(); + }); + }); + + // Live quote on input + this.shadow.querySelector('#swap-amount')?.addEventListener('input', () => this.updateSwapQuote()); + + // Execute swap + this.shadow.querySelector('#btn-swap-execute')?.addEventListener('click', () => this.handleSwapExecute()); + + // Withdraw + this.shadow.querySelector('#btn-withdraw-execute')?.addEventListener('click', () => this.handleWithdraw()); + } + + private _quoteTimer: ReturnType | null = null; + + private async updateSwapQuote() { + const input = this.shadow.querySelector('#swap-amount') as HTMLInputElement; + const quoteEl = this.shadow.querySelector('#swap-quote'); + if (!input || !quoteEl) return; + + const amount = parseFloat(input.value); + if (!amount || amount <= 0) { + quoteEl.textContent = ''; + return; + } + + // Debounce quote requests + if (this._quoteTimer) clearTimeout(this._quoteTimer); + this._quoteTimer = setTimeout(async () => { + await this._fetchQuote(amount, quoteEl); + }, 300); + } + + private async _fetchQuote(amount: number, quoteEl: Element) { + try { + const base = this.getApiBase(); + const resp = await fetch(`${base}/api/crdt-tokens/myco/quote?action=${this._swapAction}&amount=${amount}`); + const data = await resp.json(); + if (data.error) { + quoteEl.innerHTML = `${data.error}`; + return; + } + const outToken = this._swapAction === 'buy' ? '$MYCO' : 'cUSDC'; + const feeStr = data.fee ? ` | Fee: ${data.fee.amount.toFixed(4)} cUSDC` : ''; + quoteEl.innerHTML = `You receive: ${data.output.amount.toFixed(4)} ${outToken} | Price: ${data.pricePerToken.toFixed(6)} cUSDC/${this._swapAction === 'buy' ? '$MYCO' : 'cUSDC'} | Impact: ${data.priceImpact}%${feeStr}`; + } catch { + quoteEl.innerHTML = 'Quote unavailable'; + } + } + + private async handleBuyCUSDC() { + const amountInput = this.shadow.querySelector('#buy-amount') as HTMLInputElement; + const countrySelect = this.shadow.querySelector('#buy-country') as HTMLSelectElement; + const statusEl = this.shadow.querySelector('#buy-cusdc-status')!; + const amount = parseFloat(amountInput?.value || '0'); + + if (!amount || amount < 1) { + statusEl.innerHTML = 'Enter amount >= $1.00'; + return; + } + + statusEl.innerHTML = 'Creating payment...'; + + try { + const resp = await fetch(`/api/onramp/hs/create-intent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: Math.round(amount * 100), // cents + currency: 'usd', + did: this.getDid(), + label: this.getLabel(), + billingCountry: countrySelect?.value || undefined, + }), + }); + const data = await resp.json(); + + if (data.error) { + statusEl.innerHTML = `${data.error}`; + return; + } + + statusEl.innerHTML = `Payment created! Redirecting to checkout...`; + setTimeout(() => { + statusEl.innerHTML = `Payment ID: ${data.paymentId}. Complete payment at pay.rspace.online`; + }, 1500); + } catch { + statusEl.innerHTML = `Failed to create payment`; + } + } + + private async handleSwapExecute() { + const input = this.shadow.querySelector('#swap-amount') as HTMLInputElement; + const statusEl = this.shadow.querySelector('#swap-status')!; + const amount = parseFloat(input?.value || '0'); + + if (!amount || amount <= 0) { + statusEl.innerHTML = 'Enter a valid amount'; + return; + } + + statusEl.innerHTML = 'Processing swap...'; + + try { + const base = this.getApiBase(); + const endpoint = this._swapAction === 'buy' + ? `${base}/api/crdt-tokens/myco/buy` + : `${base}/api/crdt-tokens/myco/sell`; + const body = this._swapAction === 'buy' + ? { cUSDCAmount: amount } + : { mycoAmount: amount }; + + const token = this.getAuthToken(); + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + + if (data.error) { + statusEl.innerHTML = `${data.error}`; + return; + } + + statusEl.innerHTML = `Swapped! Received ${data.received.amount.toFixed(4)} ${data.received.token}`; + this.loadCRDTBalances(); + } catch { + statusEl.innerHTML = `Swap failed`; + } + } + + private async handleWithdraw() { + const amountInput = this.shadow.querySelector('#withdraw-amount') as HTMLInputElement; + const countrySelect = this.shadow.querySelector('#withdraw-country') as HTMLSelectElement; + const ibanInput = this.shadow.querySelector('#withdraw-iban') as HTMLInputElement; + const statusEl = this.shadow.querySelector('#withdraw-status')!; + const amount = parseFloat(amountInput?.value || '0'); + + if (!amount || amount < 10) { + statusEl.innerHTML = 'Minimum withdrawal: $10.00'; + return; + } + if (!ibanInput?.value) { + statusEl.innerHTML = 'Enter bank account details'; + return; + } + + statusEl.innerHTML = 'Initiating withdrawal...'; + + try { + const resp = await fetch(`/api/offramp/hs/initiate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: this.getDid(), + label: this.getLabel(), + amount, + currency: 'usd', + bankCountry: countrySelect?.value || 'US', + bankDetails: { iban: ibanInput.value }, + }), + }); + const data = await resp.json(); + + if (data.error) { + statusEl.innerHTML = `${data.error}`; + return; + } + + statusEl.innerHTML = `Withdrawal initiated! Est. arrival: ${new Date(data.estimatedArrival).toLocaleDateString()}`; + this.loadCRDTBalances(); + } catch { + statusEl.innerHTML = `Withdrawal failed`; + } + } + + private getDid(): string { + try { + const token = this.getAuthToken(); + if (!token) return ''; + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.did || payload.sub || ''; + } catch { return ''; } + } + + private getLabel(): string { + try { + const token = this.getAuthToken(); + if (!token) return ''; + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.username || payload.name || 'Unknown'; + } catch { return 'Unknown'; } + } + private render() { this.shadow.innerHTML = ` ${this.renderStyles()} @@ -2793,6 +3299,7 @@ class FolkWalletViewer extends HTMLElement { `; this.attachVisualizerListeners(); + this.attachPaymentListeners(); this._tour.renderOverlay(); } diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 353efcb..bbb42e9 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -798,7 +798,7 @@ routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => { }); // ── CRDT Token API ── -import { getTokenDoc, listTokenDocs, getAllBalances, getBalance, mintTokens, getAllTransfers } from "../../server/token-service"; +import { getTokenDoc, listTokenDocs, getAllBalances, getBalance, mintTokens, burnTokens, burnTokensEscrow, getAllTransfers } from "../../server/token-service"; import { tokenDocId } from "../../server/token-schemas"; import type { TokenLedgerDoc } from "../../server/token-schemas"; @@ -895,6 +895,197 @@ routes.get("/api/crdt-tokens/transfers", (c) => { return c.json({ entries }); }); +// ── $MYCO Bonding Curve (cUSDC ↔ $MYCO swaps) ── +import { calculatePrice, calculateBuyReturn, calculateSellReturn, getCurveConfig } from "../../server/bonding-curve"; + +/** Get current $MYCO supply from the CRDT ledger. */ +function getMycoSupply(): number { + const doc = getTokenDoc("myco"); + if (!doc || !doc.token.name) return 0; + return doc.token.totalSupply || 0; +} + +// Quote endpoint — no auth required +routes.get("/api/crdt-tokens/myco/quote", (c) => { + const action = c.req.query("action"); + const amountStr = c.req.query("amount"); + if (!action || !amountStr) { + return c.json({ error: "action (buy|sell) and amount are required" }, 400); + } + const amount = parseFloat(amountStr); + if (isNaN(amount) || amount <= 0) { + return c.json({ error: "amount must be a positive number" }, 400); + } + + const supply = getMycoSupply(); + const amountBaseUnits = Math.round(amount * 1_000_000); + + if (action === "buy") { + const result = calculateBuyReturn(amountBaseUnits, supply); + return c.json({ + action: "buy", + input: { amount, token: "cUSDC" }, + output: { amount: result.tokensOut / 1_000_000, token: "$MYCO", baseUnits: result.tokensOut }, + pricePerToken: result.averagePrice / 1_000_000, + priceImpact: result.priceImpact, + currentSupply: supply / 1_000_000, + config: getCurveConfig(), + }); + } else if (action === "sell") { + if (amountBaseUnits > supply) { + return c.json({ error: "Cannot sell more than current supply" }, 400); + } + const result = calculateSellReturn(amountBaseUnits, supply); + return c.json({ + action: "sell", + input: { amount, token: "$MYCO" }, + output: { amount: result.cUSDCOut / 1_000_000, token: "cUSDC", baseUnits: result.cUSDCOut }, + fee: { amount: result.fee / 1_000_000, bps: 50 }, + pricePerToken: result.averagePrice / 1_000_000, + priceImpact: result.priceImpact, + currentSupply: supply / 1_000_000, + config: getCurveConfig(), + }); + } + return c.json({ error: 'action must be "buy" or "sell"' }, 400); +}); + +// Buy $MYCO with cUSDC (authenticated) +routes.post("/api/crdt-tokens/myco/buy", async (c) => { + const claims = await verifyWalletAuth(c); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const body = await c.req.json<{ cUSDCAmount?: number }>(); + const { cUSDCAmount } = body; + if (!cUSDCAmount || cUSDCAmount <= 0) { + return c.json({ error: "cUSDCAmount (positive number in display units) required" }, 400); + } + + const did = (claims as any).did || claims.sub; + const amountBaseUnits = Math.round(cUSDCAmount * 1_000_000); + const supply = getMycoSupply(); + + // Check user has enough cUSDC + const cusdcDoc = getTokenDoc("cusdc"); + if (!cusdcDoc) return c.json({ error: "cUSDC token not found" }, 500); + const cusdcBalance = getBalance(cusdcDoc, did); + if (cusdcBalance < amountBaseUnits) { + return c.json({ error: `Insufficient cUSDC: have ${cusdcBalance / 1_000_000}, need ${cUSDCAmount}` }, 400); + } + + // Calculate swap + const result = calculateBuyReturn(amountBaseUnits, supply); + if (result.tokensOut <= 0) { + return c.json({ error: "Amount too small to produce tokens" }, 400); + } + + // Atomic-ish: burn cUSDC, mint $MYCO with shared swap ID + const swapId = `swap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const burnOk = burnTokens("cusdc", did, "", amountBaseUnits, `Bonding curve buy: ${swapId}`, "bonding-curve"); + if (!burnOk) return c.json({ error: "Failed to burn cUSDC" }, 500); + + const mintOk = mintTokens("myco", did, "", result.tokensOut, `Bonding curve buy: ${swapId}`, "bonding-curve"); + if (!mintOk) { + // Compensate: re-mint the burned cUSDC + mintTokens("cusdc", did, "", amountBaseUnits, `Refund: failed swap ${swapId}`, "bonding-curve"); + return c.json({ error: "Failed to mint $MYCO, cUSDC refunded" }, 500); + } + + return c.json({ + ok: true, + swapId, + burned: { token: "cUSDC", amount: amountBaseUnits / 1_000_000 }, + received: { token: "$MYCO", amount: result.tokensOut / 1_000_000 }, + pricePerToken: result.averagePrice / 1_000_000, + priceImpact: result.priceImpact, + }); +}); + +// Sell $MYCO for cUSDC (authenticated) +routes.post("/api/crdt-tokens/myco/sell", async (c) => { + const claims = await verifyWalletAuth(c); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const body = await c.req.json<{ mycoAmount?: number }>(); + const { mycoAmount } = body; + if (!mycoAmount || mycoAmount <= 0) { + return c.json({ error: "mycoAmount (positive number in display units) required" }, 400); + } + + const did = (claims as any).did || claims.sub; + const amountBaseUnits = Math.round(mycoAmount * 1_000_000); + const supply = getMycoSupply(); + + // Check user has enough $MYCO + const mycoDoc = getTokenDoc("myco"); + if (!mycoDoc) return c.json({ error: "$MYCO token not found" }, 500); + const mycoBalance = getBalance(mycoDoc, did); + if (mycoBalance < amountBaseUnits) { + return c.json({ error: `Insufficient $MYCO: have ${mycoBalance / 1_000_000}, need ${mycoAmount}` }, 400); + } + + // Calculate swap + const result = calculateSellReturn(amountBaseUnits, supply); + if (result.cUSDCOut <= 0) { + return c.json({ error: "Amount too small to produce cUSDC" }, 400); + } + + // Atomic-ish: burn $MYCO, mint cUSDC with shared swap ID + const swapId = `swap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const burnOk = burnTokens("myco", did, "", amountBaseUnits, `Bonding curve sell: ${swapId}`, "bonding-curve"); + if (!burnOk) return c.json({ error: "Failed to burn $MYCO" }, 500); + + const mintOk = mintTokens("cusdc", did, "", result.cUSDCOut, `Bonding curve sell: ${swapId}`, "bonding-curve"); + if (!mintOk) { + // Compensate: re-mint the burned $MYCO + mintTokens("myco", did, "", amountBaseUnits, `Refund: failed swap ${swapId}`, "bonding-curve"); + return c.json({ error: "Failed to mint cUSDC, $MYCO refunded" }, 500); + } + + return c.json({ + ok: true, + swapId, + burned: { token: "$MYCO", amount: amountBaseUnits / 1_000_000 }, + received: { token: "cUSDC", amount: result.cUSDCOut / 1_000_000 }, + fee: { token: "cUSDC", amount: result.fee / 1_000_000, bps: 50 }, + pricePerToken: result.averagePrice / 1_000_000, + priceImpact: result.priceImpact, + }); +}); + +// Settlement state — for bonding-curve-service cron to read CRDT state for on-chain sync +routes.get("/api/crdt-tokens/myco/settlement-state", (c) => { + const mycoDoc = getTokenDoc("myco"); + const cusdcDoc = getTokenDoc("cusdc"); + + const mycoSupply = mycoDoc?.token.totalSupply || 0; + const currentPrice = calculatePrice(mycoSupply); + + // Calculate reserve: sum of all cUSDC burned via bonding curve buys + let reserveBalance = 0; + if (cusdcDoc) { + for (const entry of Object.values(cusdcDoc.entries)) { + if (entry.type === 'burn' && entry.memo.startsWith('Bonding curve buy:')) { + reserveBalance += entry.amount; + } else if (entry.type === 'mint' && entry.memo.startsWith('Bonding curve sell:')) { + reserveBalance -= entry.amount; + } + } + } + + return c.json({ + mycoSupply: mycoSupply / 1_000_000, + mycoSupplyBaseUnits: mycoSupply, + reserveBalance: reserveBalance / 1_000_000, + reserveBalanceBaseUnits: reserveBalance, + currentPrice: currentPrice / 1_000_000, + currentPriceBaseUnits: currentPrice, + marketCap: (mycoSupply * currentPrice) / (1_000_000 * 1_000_000), + config: getCurveConfig(), + timestamp: Date.now(), + }); +}); + // ── Yield API routes ── import { getYieldRates } from "./lib/yield-rates"; import { getYieldPositions } from "./lib/yield-positions"; diff --git a/scripts/test-full-loop.ts b/scripts/test-full-loop.ts new file mode 100644 index 0000000..46e9cc3 --- /dev/null +++ b/scripts/test-full-loop.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env bun +/** + * Full-loop integration test: fiat in → cUSDC → $MYCO → cUSDC → fiat out. + * + * Tests all 5 phases of the HyperSwitch payment orchestrator integration. + * Run against a local dev instance with: bun scripts/test-full-loop.ts + * + * Expects: + * - rspace-online running at BASE_URL (default: http://localhost:3000) + * - payment-infra services running (or mock responses) + * - INTERNAL_API_KEY set for internal endpoints + */ + +const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; +const INTERNAL_KEY = process.env.INTERNAL_API_KEY || "test-internal-key"; +const TEST_DID = process.env.TEST_DID || "did:key:test-full-loop-2026"; +const TEST_LABEL = "test-user"; + +type StepResult = { ok: boolean; data?: any; error?: string }; + +async function step(name: string, fn: () => Promise) { + process.stdout.write(` ${name}... `); + try { + const result = await fn(); + if (result.ok) { + console.log("OK", result.data ? JSON.stringify(result.data).slice(0, 120) : ""); + } else { + console.log("FAIL", result.error || JSON.stringify(result.data)); + } + return result; + } catch (err: any) { + console.log("ERROR", err.message); + return { ok: false, error: err.message }; + } +} + +async function main() { + console.log("\n=== HyperSwitch Full Loop Test ===\n"); + console.log(`Base URL: ${BASE_URL}`); + console.log(`Test DID: ${TEST_DID}\n`); + + // ── Phase 1: Health check ── + console.log("Phase 1: Infrastructure"); + await step("rspace-online health", async () => { + const resp = await fetch(`${BASE_URL}/api/communities`); + return { ok: resp.ok, data: { status: resp.status } }; + }); + + // ── Phase 2: Fiat on-ramp (simulate mint-crdt call) ── + console.log("\nPhase 2: Fiat On-Ramp (cUSDC mint)"); + + const mintResult = await step("POST /api/internal/mint-crdt", async () => { + const resp = await fetch(`${BASE_URL}/api/internal/mint-crdt`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Internal-Key": INTERNAL_KEY, + }, + body: JSON.stringify({ + did: TEST_DID, + label: TEST_LABEL, + amountDecimal: "100.000000", + txHash: `hs-test-${Date.now()}`, + network: "hyperswitch:fiat:usd", + }), + }); + const data = await resp.json(); + return { ok: resp.ok && data.ok, data }; + }); + + await step("Idempotency check (same txHash)", async () => { + const resp = await fetch(`${BASE_URL}/api/internal/mint-crdt`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Internal-Key": INTERNAL_KEY, + }, + body: JSON.stringify({ + did: TEST_DID, + label: TEST_LABEL, + amountDecimal: "100.000000", + txHash: mintResult.data?.txHash || `hs-test-idempotent`, + network: "hyperswitch:fiat:usd", + }), + }); + const data = await resp.json(); + // Should return ok:false because already minted + return { ok: resp.ok && !data.ok, data }; + }); + + // ── Phase 3: $MYCO bonding curve ── + console.log("\nPhase 3: $MYCO Bonding Curve"); + + await step("GET /api/crdt-tokens/myco/quote?action=buy&amount=10", async () => { + const resp = await fetch(`${BASE_URL}/demo/rwallet/api/crdt-tokens/myco/quote?action=buy&amount=10`); + const data = await resp.json(); + return { + ok: resp.ok && data.output?.amount > 0, + data: { output: data.output?.amount, price: data.pricePerToken, impact: data.priceImpact }, + }; + }); + + await step("GET /api/crdt-tokens/myco/quote?action=sell&amount=100", async () => { + const resp = await fetch(`${BASE_URL}/demo/rwallet/api/crdt-tokens/myco/quote?action=sell&amount=100`); + const data = await resp.json(); + return { + ok: resp.ok && data.output?.amount > 0, + data: { output: data.output?.amount, fee: data.fee?.amount, impact: data.priceImpact }, + }; + }); + + await step("GET /api/crdt-tokens/myco/settlement-state", async () => { + const resp = await fetch(`${BASE_URL}/demo/rwallet/api/crdt-tokens/myco/settlement-state`); + const data = await resp.json(); + return { + ok: resp.ok && data.mycoSupply >= 0, + data: { supply: data.mycoSupply, price: data.currentPrice, reserve: data.reserveBalance }, + }; + }); + + // ── Phase 4: Off-ramp (simulate escrow + confirm) ── + console.log("\nPhase 4: Fiat Off-Ramp (escrow burn)"); + + const offRampId = `offramp-test-${Date.now()}`; + await step("POST /api/internal/escrow-burn", async () => { + const resp = await fetch(`${BASE_URL}/api/internal/escrow-burn`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Internal-Key": INTERNAL_KEY, + }, + body: JSON.stringify({ + did: TEST_DID, + label: TEST_LABEL, + amount: 10_000_000, // 10 cUSDC + offRampId, + }), + }); + const data = await resp.json(); + return { ok: resp.ok && data.ok, data }; + }); + + await step("POST /api/internal/confirm-offramp (confirmed)", async () => { + const resp = await fetch(`${BASE_URL}/api/internal/confirm-offramp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Internal-Key": INTERNAL_KEY, + }, + body: JSON.stringify({ offRampId, status: "confirmed" }), + }); + const data = await resp.json(); + return { ok: resp.ok && data.ok, data }; + }); + + // Test reversal flow + const offRampId2 = `offramp-test-reverse-${Date.now()}`; + await step("Escrow + reverse flow", async () => { + // Escrow + const escrowResp = await fetch(`${BASE_URL}/api/internal/escrow-burn`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Internal-Key": INTERNAL_KEY, + }, + body: JSON.stringify({ + did: TEST_DID, + label: TEST_LABEL, + amount: 5_000_000, // 5 cUSDC + offRampId: offRampId2, + }), + }); + if (!escrowResp.ok) return { ok: false, error: "escrow failed" }; + + // Reverse + const reverseResp = await fetch(`${BASE_URL}/api/internal/confirm-offramp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Internal-Key": INTERNAL_KEY, + }, + body: JSON.stringify({ offRampId: offRampId2, status: "reversed" }), + }); + const data = await reverseResp.json(); + return { ok: reverseResp.ok && data.ok, data: { action: "reversed", refunded: "5 cUSDC" } }; + }); + + // ── Phase 5: CRDT token list ── + console.log("\nPhase 5: Token Verification"); + + await step("GET /api/crdt-tokens (list all tokens)", async () => { + const resp = await fetch(`${BASE_URL}/demo/rwallet/api/crdt-tokens`); + const data = await resp.json(); + const symbols = (data.tokens || []).map((t: any) => t.symbol); + return { + ok: resp.ok && symbols.includes("cUSDC") && symbols.includes("$MYCO"), + data: { tokens: symbols }, + }; + }); + + console.log("\n=== Full Loop Test Complete ===\n"); +} + +main().catch(console.error); diff --git a/server/bonding-curve.ts b/server/bonding-curve.ts new file mode 100644 index 0000000..a62ac46 --- /dev/null +++ b/server/bonding-curve.ts @@ -0,0 +1,97 @@ +/** + * CRDT-native bonding curve — pure math functions for $MYCO ↔ cUSDC swaps. + * + * Polynomial curve: price = basePrice + coefficient × supply² + * Ported from payment-infra/services/bonding-curve-service for CRDT ledger integration. + * + * All amounts in base units: + * - cUSDC: 6 decimals (1_000_000 = $1.00) + * - $MYCO: 6 decimals (using 6 effective decimals same as cUSDC for simplicity) + */ + +// ── Curve parameters ── + +/** Base price in cUSDC units (6 decimals) — $0.01 */ +const BASE_PRICE = 10_000; + +/** Coefficient — controls curve steepness */ +const COEFFICIENT = 0.000001; + +/** Exponent — quadratic curve */ +const EXPONENT = 2; + +/** Sell fee in basis points (0.5% = 50 bps) */ +const SELL_FEE_BPS = 50; + +// ── Price functions ── + +/** Calculate token price at a given supply (both in base units). Returns cUSDC base units per MYCO. */ +export function calculatePrice(supplyBaseUnits: number): number { + const supplyTokens = supplyBaseUnits / 1_000_000; + return Math.round(BASE_PRICE + COEFFICIENT * Math.pow(supplyTokens, EXPONENT) * 1_000_000); +} + +/** Calculate tokens received for cUSDC input (buy). */ +export function calculateBuyReturn(cUSDCAmount: number, currentSupply: number): { + tokensOut: number; + averagePrice: number; + priceImpact: number; + endPrice: number; +} { + const startPrice = calculatePrice(currentSupply); + + // First estimate with start price + const estimatedTokens = Math.floor((cUSDCAmount * 1_000_000) / startPrice); + const endPrice = calculatePrice(currentSupply + estimatedTokens); + const averagePrice = Math.round((startPrice + endPrice) / 2); + + // Recalculate with average price + const tokensOut = Math.floor((cUSDCAmount * 1_000_000) / averagePrice); + + // Price impact as percentage (0-100) + const priceImpact = startPrice > 0 + ? Number(((endPrice - startPrice) / startPrice * 100).toFixed(2)) + : 0; + + return { tokensOut, averagePrice, priceImpact, endPrice }; +} + +/** Calculate cUSDC received for $MYCO input (sell), after fee. */ +export function calculateSellReturn(mycoAmount: number, currentSupply: number): { + cUSDCOut: number; + grossAmount: number; + fee: number; + averagePrice: number; + priceImpact: number; + endPrice: number; +} { + if (mycoAmount > currentSupply) { + throw new Error('Cannot sell more than current supply'); + } + + const startPrice = calculatePrice(currentSupply); + const endPrice = calculatePrice(currentSupply - mycoAmount); + const averagePrice = Math.round((startPrice + endPrice) / 2); + + const grossAmount = Math.floor((mycoAmount * averagePrice) / 1_000_000); + const fee = Math.floor((grossAmount * SELL_FEE_BPS) / 10_000); + const cUSDCOut = grossAmount - fee; + + const priceImpact = startPrice > 0 + ? Number(((startPrice - endPrice) / startPrice * 100).toFixed(2)) + : 0; + + return { cUSDCOut, grossAmount, fee, averagePrice, priceImpact, endPrice }; +} + +/** Get current curve configuration (for UI display). */ +export function getCurveConfig() { + return { + basePrice: BASE_PRICE, + coefficient: COEFFICIENT, + exponent: EXPONENT, + sellFeeBps: SELL_FEE_BPS, + priceDecimals: 6, + tokenDecimals: 6, + }; +} diff --git a/server/token-schemas.ts b/server/token-schemas.ts index 07a27ad..33905e6 100644 --- a/server/token-schemas.ts +++ b/server/token-schemas.ts @@ -21,6 +21,10 @@ export interface LedgerEntry { issuedBy: string; txHash?: string; onChainNetwork?: string; + /** Off-ramp ID for escrow burns */ + offRampId?: string; + /** Escrow status: 'escrow' → 'confirmed' | 'reversed' */ + status?: 'escrow' | 'confirmed' | 'reversed'; } export interface TokenDefinition { diff --git a/server/token-service.ts b/server/token-service.ts index e9472ec..cc6193e 100644 --- a/server/token-service.ts +++ b/server/token-service.ts @@ -29,10 +29,12 @@ function ensureTokenDoc(tokenId: string): TokenLedgerDoc { return doc; } -/** Sum all entry amounts for a given DID holder. */ +/** Sum all entry amounts for a given DID holder. Excludes reversed burns. */ export function getBalance(doc: TokenLedgerDoc, did: string): number { let balance = 0; for (const entry of Object.values(doc.entries)) { + // Skip reversed burns (the compensating mint handles the refund) + if (entry.type === 'burn' && (entry as any).status === 'reversed') continue; if (entry.type === 'mint' && entry.to === did) { balance += entry.amount; } else if (entry.type === 'transfer') { @@ -206,6 +208,118 @@ export function mintFromOnChain( return success; } +/** Burn tokens from a DID. */ +export function burnTokens( + tokenId: string, + fromDid: string, + fromLabel: string, + amount: number, + memo: string, + issuedBy: string, + timestamp?: number, +): boolean { + const docId = tokenDocId(tokenId); + ensureTokenDoc(tokenId); + + const entryId = `burn-${timestamp || Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const result = _syncServer!.changeDoc(docId, `burn ${amount} from ${fromLabel}`, (d) => { + d.entries[entryId] = { + id: entryId, + to: '', + toLabel: '', + amount, + memo, + type: 'burn', + from: fromDid, + timestamp: timestamp || Date.now(), + issuedBy, + }; + d.token.totalSupply = Math.max(0, (d.token.totalSupply || 0) - amount); + }); + return result !== null; +} + +/** + * Burn tokens into escrow (Phase 4 off-ramp). + * Creates a burn entry with offRampId + status:'escrow'. Balance reduced immediately. + */ +export function burnTokensEscrow( + tokenId: string, + fromDid: string, + fromLabel: string, + amount: number, + offRampId: string, + memo: string, +): boolean { + const docId = tokenDocId(tokenId); + ensureTokenDoc(tokenId); + + const entryId = `escrow-${offRampId}`; + const result = _syncServer!.changeDoc(docId, `escrow burn ${amount} from ${fromLabel}`, (d) => { + d.entries[entryId] = { + id: entryId, + to: '', + toLabel: '', + amount, + memo, + type: 'burn', + from: fromDid, + timestamp: Date.now(), + issuedBy: 'offramp-service', + offRampId, + status: 'escrow', + } as any; // extended fields + d.token.totalSupply = Math.max(0, (d.token.totalSupply || 0) - amount); + }); + return result !== null; +} + +/** Confirm an escrow burn (payout succeeded). */ +export function confirmBurn(tokenId: string, offRampId: string): boolean { + const docId = tokenDocId(tokenId); + const doc = ensureTokenDoc(tokenId); + const entryId = `escrow-${offRampId}`; + if (!doc.entries[entryId]) return false; + + const result = _syncServer!.changeDoc(docId, `confirm escrow ${offRampId}`, (d) => { + if (d.entries[entryId]) { + (d.entries[entryId] as any).status = 'confirmed'; + } + }); + return result !== null; +} + +/** Reverse an escrow burn (payout failed) — mints tokens back to the original holder. */ +export function reverseBurn(tokenId: string, offRampId: string): boolean { + const docId = tokenDocId(tokenId); + const doc = ensureTokenDoc(tokenId); + const entryId = `escrow-${offRampId}`; + const entry = doc.entries[entryId]; + if (!entry) return false; + + const result = _syncServer!.changeDoc(docId, `reverse escrow ${offRampId}`, (d) => { + // Mark original burn as reversed + if (d.entries[entryId]) { + (d.entries[entryId] as any).status = 'reversed'; + } + // Compensating mint to refund the holder + const refundId = `refund-${offRampId}`; + d.entries[refundId] = { + id: refundId, + to: entry.from, + toLabel: '', + amount: entry.amount, + memo: `Refund: off-ramp ${offRampId} reversed`, + type: 'mint', + from: '', + timestamp: Date.now(), + issuedBy: 'offramp-service', + }; + d.token.totalSupply = (d.token.totalSupply || 0) + entry.amount; + }); + return result !== null; +} + /** List all token doc IDs. */ export function listTokenDocs(): string[] { return _syncServer!.listDocs().filter((id) => id.startsWith('global:tokens:ledgers:')); @@ -218,9 +332,9 @@ export function getTokenDoc(tokenId: string): TokenLedgerDoc | undefined { /** Seed the full DAO token ecosystem — BFT governance, cUSDC stablecoin, multiple treasuries. */ export async function seedCUSDC() { - // Check if already seeded by looking at BFT token (last one created) - const bftDoc = getTokenDoc('bft'); - if (bftDoc && bftDoc.token.name && Object.keys(bftDoc.entries).length > 0) { + // Check if already seeded by looking at $MYCO token (last one created) + const mycoDoc = getTokenDoc('myco'); + if (mycoDoc && mycoDoc.token.name && Object.keys(mycoDoc.entries).length > 0) { console.log('[TokenService] DAO ecosystem already seeded, skipping'); return; } @@ -269,36 +383,35 @@ export async function seedCUSDC() { transferTokens('cusdc', treasuryDid, 'DAO Treasury', devFundDid, 'Dev Fund', 50_000_000_000, 'Q2 2026 dev budget (early)', 'system', t(5)); transferTokens('cusdc', grantsDid, 'Grants Committee', aliceDid, 'Alice', 20_000_000_000, 'Grant: rMaps privacy module', 'system', t(3)); - // ── 2. BFT — Governance token ── - ensureTokenDef('bft', { - name: 'BioFi Token', symbol: 'BFT', decimals: 18, - description: 'Governance token for the rSpace DAO — used for voting, delegation, and reputation', + // ── 2. $MYCO — Governance token (bonding curve) ── + ensureTokenDef('myco', { + name: 'MYCO Token', symbol: '$MYCO', decimals: 6, + description: 'Governance token for the rSpace DAO — bonding curve with cUSDC reserve, used for voting and delegation', icon: '🌱', color: '#22c55e', }); - const e18 = 1_000_000_000_000_000_000; // 10^18 — but we store as number so use smaller scale - const bft = (n: number) => n * 1_000_000; // Use 6 effective decimals for demo (display divides by 10^18) + const myco = (n: number) => n * 1_000_000; // 6 effective decimals // Genesis mint to treasury - mintTokens('bft', treasuryDid, 'DAO Treasury', bft(10_000_000), 'Genesis: 10M BFT', 'system', t(58)); + mintTokens('myco', treasuryDid, 'DAO Treasury', myco(10_000_000), 'Genesis: 10M $MYCO', 'system', t(58)); // Distribute to sub-treasuries - transferTokens('bft', treasuryDid, 'DAO Treasury', grantsDid, 'Grants Committee', bft(2_000_000), 'Grants committee allocation', 'system', t(56)); - transferTokens('bft', treasuryDid, 'DAO Treasury', devFundDid, 'Dev Fund', bft(1_500_000), 'Dev fund allocation', 'system', t(56)); + transferTokens('myco', treasuryDid, 'DAO Treasury', grantsDid, 'Grants Committee', myco(2_000_000), 'Grants committee allocation', 'system', t(56)); + transferTokens('myco', treasuryDid, 'DAO Treasury', devFundDid, 'Dev Fund', myco(1_500_000), 'Dev fund allocation', 'system', t(56)); // Vest to contributors - transferTokens('bft', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', bft(500_000), 'Founder vesting — month 1', 'system', t(50)); - transferTokens('bft', grantsDid, 'Grants Committee', aliceDid, 'Alice', bft(100_000), 'Contributor reward: sync engine', 'system', t(46)); - transferTokens('bft', grantsDid, 'Grants Committee', bobDid, 'Bob', bft(50_000), 'Contributor reward: audit', 'system', t(43)); - transferTokens('bft', grantsDid, 'Grants Committee', carolDid, 'Carol', bft(75_000), 'Contributor reward: UX', 'system', t(40)); - transferTokens('bft', devFundDid, 'Dev Fund', jeffDid, 'jeff', bft(200_000), 'Dev milestone: rwallet launch', 'system', t(35)); + transferTokens('myco', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', myco(500_000), 'Founder vesting — month 1', 'system', t(50)); + transferTokens('myco', grantsDid, 'Grants Committee', aliceDid, 'Alice', myco(100_000), 'Contributor reward: sync engine', 'system', t(46)); + transferTokens('myco', grantsDid, 'Grants Committee', bobDid, 'Bob', myco(50_000), 'Contributor reward: audit', 'system', t(43)); + transferTokens('myco', grantsDid, 'Grants Committee', carolDid, 'Carol', myco(75_000), 'Contributor reward: UX', 'system', t(40)); + transferTokens('myco', devFundDid, 'Dev Fund', jeffDid, 'jeff', myco(200_000), 'Dev milestone: rwallet launch', 'system', t(35)); // Second vesting round - transferTokens('bft', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', bft(500_000), 'Founder vesting — month 2', 'system', t(20)); - transferTokens('bft', grantsDid, 'Grants Committee', aliceDid, 'Alice', bft(150_000), 'Grant reward: rMaps', 'system', t(15)); - transferTokens('bft', devFundDid, 'Dev Fund', bobDid, 'Bob', bft(80_000), 'Security bounty payout', 'system', t(12)); - transferTokens('bft', devFundDid, 'Dev Fund', carolDid, 'Carol', bft(60_000), 'Design system completion', 'system', t(8)); + transferTokens('myco', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', myco(500_000), 'Founder vesting — month 2', 'system', t(20)); + transferTokens('myco', grantsDid, 'Grants Committee', aliceDid, 'Alice', myco(150_000), 'Grant reward: rMaps', 'system', t(15)); + transferTokens('myco', devFundDid, 'Dev Fund', bobDid, 'Bob', myco(80_000), 'Security bounty payout', 'system', t(12)); + transferTokens('myco', devFundDid, 'Dev Fund', carolDid, 'Carol', myco(60_000), 'Design system completion', 'system', t(8)); // Recent - transferTokens('bft', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', bft(500_000), 'Founder vesting — month 3', 'system', t(2)); + transferTokens('myco', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', myco(500_000), 'Founder vesting — month 3', 'system', t(2)); - console.log('[TokenService] DAO ecosystem seeded: cUSDC + BFT tokens with treasury flows'); + console.log('[TokenService] DAO ecosystem seeded: cUSDC + $MYCO tokens with treasury flows'); } /** Helper to define a token if not yet defined. */