diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 019c481..acbea4f 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -98,6 +98,10 @@ class FolkWalletViewer extends HTMLElement { private linkingInProgress = false; private linkError = ""; + // CRDT token balances + private crdtBalances: Array<{ tokenId: string; name: string; symbol: string; decimals: number; icon: string; color: string; balance: number }> = []; + private crdtLoading = false; + // Visualization state private activeView: ViewTab = "balances"; private transfers: Map | null = null; @@ -154,6 +158,7 @@ class FolkWalletViewer extends HTMLElement { this.isAuthenticated = true; this.passKeyEOA = parsed.claims?.eid?.walletAddress || ""; this.loadLinkedWallets(); + this.loadCRDTBalances(); } } } catch {} @@ -195,6 +200,58 @@ class FolkWalletViewer extends HTMLElement { } catch {} } + private async loadCRDTBalances() { + const token = this.getAuthToken(); + if (!token) return; + this.crdtLoading = true; + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/crdt-tokens/my-balances`, { + headers: { "Authorization": `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = await res.json(); + this.crdtBalances = data.balances || []; + } catch {} + this.crdtLoading = false; + this.render(); + } + + private renderLocalTokens(): string { + if (!this.isAuthenticated) return ""; + if (this.crdtLoading) { + return `
Loading local tokens...
`; + } + if (this.crdtBalances.length === 0) return ""; + + const rows = this.crdtBalances.map((t) => { + const formatted = (t.balance / Math.pow(10, t.decimals)).toFixed(2); + return ` + + ${t.icon || '🪙'} + ${this.esc(t.symbol)} + + ${this.esc(t.name)} + ${formatted} + `; + }).join(''); + + return ` +
+

Local Tokens (CRDT)

+ + + + + + + + + ${rows} +
TokenNameBalance
+
`; + } + private loadDemoData() { this.isDemo = true; this.address = "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1"; @@ -955,6 +1012,20 @@ class FolkWalletViewer extends HTMLElement { background: rgba(239,83,80,0.08); border: 1px solid rgba(239,83,80,0.2); border-radius: 10px; margin-bottom: 16px; } + .local-tokens-section { + background: var(--rs-surface, #1a1a2e); + border: 1px solid #2775ca44; + border-left: 3px solid #2775ca; + border-radius: 10px; + padding: 16px 20px; + margin-bottom: 16px; + } + .local-tokens-section table td { + padding: 6px 8px; + border-bottom: 1px solid rgba(255,255,255,0.06); + color: #e0e0e0; + } + .local-tokens-section table tr:last-child td { border-bottom: none; } .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; @@ -1236,6 +1307,7 @@ class FolkWalletViewer extends HTMLElement { ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Detecting wallet across chains...
' : ""} + ${this.renderLocalTokens()} ${this.renderFeatures()} ${this.renderExamples()} ${this.renderDashboard()} diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 88538c3..2e3b3ff 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -556,6 +556,97 @@ routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => { return c.json(await res.json(), 201); }); +// ── CRDT Token API ── +import { getTokenDoc, listTokenDocs, getAllBalances, getBalance, mintTokens } from "../../server/token-service"; +import { tokenDocId } from "../../server/token-schemas"; +import type { TokenLedgerDoc } from "../../server/token-schemas"; + +// List all CRDT tokens with metadata +routes.get("/api/crdt-tokens", (c) => { + const docIds = listTokenDocs(); + const tokens = docIds.map((docId) => { + const tokenId = docId.replace('global:tokens:ledgers:', ''); + const doc = getTokenDoc(tokenId); + if (!doc) return null; + return { + id: doc.token.id, + name: doc.token.name, + symbol: doc.token.symbol, + decimals: doc.token.decimals, + description: doc.token.description, + totalSupply: doc.token.totalSupply, + icon: doc.token.icon, + color: doc.token.color, + createdAt: doc.token.createdAt, + }; + }).filter(Boolean); + return c.json({ tokens }); +}); + +// Get authenticated user's CRDT token balances +routes.get("/api/crdt-tokens/my-balances", async (c) => { + const claims = await verifyWalletAuth(c); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const did = claims.sub; + const docIds = listTokenDocs(); + const balances = docIds.map((docId) => { + const tokenId = docId.replace('global:tokens:ledgers:', ''); + const doc = getTokenDoc(tokenId); + if (!doc) return null; + const balance = getBalance(doc, did); + if (balance <= 0) return null; + return { + tokenId: doc.token.id, + name: doc.token.name, + symbol: doc.token.symbol, + decimals: doc.token.decimals, + icon: doc.token.icon, + color: doc.token.color, + balance, + }; + }).filter(Boolean); + return c.json({ balances }); +}); + +// Get all holder balances for a specific token +routes.get("/api/crdt-tokens/:tokenId/balances", (c) => { + const tokenId = c.req.param("tokenId"); + const doc = getTokenDoc(tokenId); + if (!doc || !doc.token.name) return c.json({ error: "Token not found" }, 404); + + const holders = getAllBalances(doc); + return c.json({ + token: { + id: doc.token.id, + name: doc.token.name, + symbol: doc.token.symbol, + decimals: doc.token.decimals, + }, + holders: Object.values(holders), + }); +}); + +// Mint tokens (authenticated) +routes.post("/api/crdt-tokens/:tokenId/mint", async (c) => { + const claims = await verifyWalletAuth(c); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const tokenId = c.req.param("tokenId"); + const doc = getTokenDoc(tokenId); + if (!doc || !doc.token.name) return c.json({ error: "Token not found" }, 404); + + const body = await c.req.json(); + const { toDid, toLabel, amount, memo } = body; + if (!toDid || !amount || typeof amount !== 'number' || amount <= 0) { + return c.json({ error: "Invalid mint parameters: toDid, amount (positive number) required" }, 400); + } + + const success = mintTokens(tokenId, toDid, toLabel || '', amount, memo || '', claims.sub); + if (!success) return c.json({ error: "Mint failed" }, 500); + return c.json({ ok: true, minted: amount, to: toDid }); +}); + // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; diff --git a/server/index.ts b/server/index.ts index 8a73c19..3673507 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2707,8 +2707,9 @@ import { mkdirSync } from "node:fs"; try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), { recursive: true }); } catch {} ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e)); +import { initTokenService, seedCUSDC } from "./token-service"; loadAllDocs(syncServer) - .then(() => { + .then(async () => { ensureTemplateSeeding(); // Seed all modules' demo data so /demo routes always have content for (const mod of getAllModules()) { @@ -2717,6 +2718,14 @@ loadAllDocs(syncServer) } } console.log("[Demo] All module demo data seeded"); + + // Initialize CRDT token service and seed cUSDC + initTokenService(syncServer); + try { + await seedCUSDC(); + } catch (e) { + console.error("[TokenService] Seed failed:", e); + } }) .catch((e) => console.error("[DocStore] Startup load failed:", e)); diff --git a/server/token-schemas.ts b/server/token-schemas.ts new file mode 100644 index 0000000..0402221 --- /dev/null +++ b/server/token-schemas.ts @@ -0,0 +1,83 @@ +/** + * CRDT Token Ledger — Automerge document schemas. + * + * Stores token definitions and ledger entries as Automerge CRDTs. + * DocId format: global:tokens:ledgers:{tokenId} + */ + +import type { DocSchema } from '../shared/local-first/document'; + +// ── Document types ── + +export interface LedgerEntry { + id: string; + to: string; + toLabel: string; + amount: number; + memo: string; + type: 'mint' | 'transfer' | 'burn'; + from: string; + timestamp: number; + issuedBy: string; +} + +export interface TokenDefinition { + id: string; + name: string; + symbol: string; + decimals: number; + description: string; + totalSupply: number; + icon: string; + color: string; + createdAt: number; + createdBy: string; +} + +export interface TokenLedgerDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + token: TokenDefinition; + entries: Record; +} + +// ── Schema registration ── + +export const tokenLedgerSchema: DocSchema = { + module: 'tokens', + collection: 'ledgers', + version: 1, + init: (): TokenLedgerDoc => ({ + meta: { + module: 'tokens', + collection: 'ledgers', + version: 1, + spaceSlug: 'global', + createdAt: Date.now(), + }, + token: { + id: '', + name: '', + symbol: '', + decimals: 6, + description: '', + totalSupply: 0, + icon: '', + color: '', + createdAt: Date.now(), + createdBy: '', + }, + entries: {}, + }), +}; + +// ── Helpers ── + +export function tokenDocId(tokenId: string) { + return `global:tokens:ledgers:${tokenId}` as const; +} diff --git a/server/token-service.ts b/server/token-service.ts new file mode 100644 index 0000000..406e616 --- /dev/null +++ b/server/token-service.ts @@ -0,0 +1,155 @@ +/** + * CRDT Token Service — server-side token management via Automerge. + * + * Manages token ledger documents: minting, balance queries, and seeding. + */ + +import * as Automerge from '@automerge/automerge'; +import type { SyncServer } from './local-first/sync-server'; +import { tokenLedgerSchema, tokenDocId } from './token-schemas'; +import type { TokenLedgerDoc, LedgerEntry } from './token-schemas'; +import { getUserByUsername } from '../src/encryptid/db'; + +let _syncServer: SyncServer | null = null; + +export function initTokenService(syncServer: SyncServer) { + _syncServer = syncServer; +} + +/** Create a token doc if it doesn't exist yet. */ +function ensureTokenDoc(tokenId: string): TokenLedgerDoc { + const docId = tokenDocId(tokenId); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init token ledger', (d) => { + const init = tokenLedgerSchema.init(); + Object.assign(d, init); + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +/** Sum all entry amounts for a given DID holder. */ +export function getBalance(doc: TokenLedgerDoc, did: string): number { + let balance = 0; + for (const entry of Object.values(doc.entries)) { + if (entry.type === 'mint' && entry.to === did) { + balance += entry.amount; + } else if (entry.type === 'transfer') { + if (entry.to === did) balance += entry.amount; + if (entry.from === did) balance -= entry.amount; + } else if (entry.type === 'burn' && entry.from === did) { + balance -= entry.amount; + } + } + return balance; +} + +/** Aggregate balances for all holders. */ +export function getAllBalances(doc: TokenLedgerDoc): Record { + const holders: Record = {}; + + for (const entry of Object.values(doc.entries)) { + if (entry.type === 'mint') { + if (!holders[entry.to]) holders[entry.to] = { did: entry.to, label: entry.toLabel, balance: 0 }; + holders[entry.to].balance += entry.amount; + if (entry.toLabel) holders[entry.to].label = entry.toLabel; + } else if (entry.type === 'transfer') { + if (!holders[entry.to]) holders[entry.to] = { did: entry.to, label: entry.toLabel, balance: 0 }; + if (!holders[entry.from]) holders[entry.from] = { did: entry.from, label: '', balance: 0 }; + holders[entry.to].balance += entry.amount; + holders[entry.from].balance -= entry.amount; + } else if (entry.type === 'burn') { + if (!holders[entry.from]) holders[entry.from] = { did: entry.from, label: '', balance: 0 }; + holders[entry.from].balance -= entry.amount; + } + } + + // Remove zero-balance holders + for (const [k, v] of Object.entries(holders)) { + if (v.balance <= 0) delete holders[k]; + } + return holders; +} + +/** Mint tokens to a DID. */ +export function mintTokens( + tokenId: string, + toDid: string, + toLabel: string, + amount: number, + memo: string, + issuedBy: string, +): boolean { + const docId = tokenDocId(tokenId); + ensureTokenDoc(tokenId); + + const entryId = `mint-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const result = _syncServer!.changeDoc(docId, `mint ${amount} to ${toLabel}`, (d) => { + d.entries[entryId] = { + id: entryId, + to: toDid, + toLabel, + amount, + memo, + type: 'mint', + from: '', + timestamp: Date.now(), + issuedBy, + }; + d.token.totalSupply = (d.token.totalSupply || 0) + amount; + }); + return result !== null; +} + +/** List all token doc IDs. */ +export function listTokenDocs(): string[] { + return _syncServer!.listDocs().filter((id) => id.startsWith('global:tokens:ledgers:')); +} + +/** Get a token doc by ID. */ +export function getTokenDoc(tokenId: string): TokenLedgerDoc | undefined { + return _syncServer!.getDoc(tokenDocId(tokenId)); +} + +/** Seed the cUSDC token with 5 cUSDC minted to jeff. */ +export async function seedCUSDC() { + const tokenId = 'cusdc'; + const doc = ensureTokenDoc(tokenId); + + // Skip if already seeded + if (doc.token.name) { + console.log('[TokenService] cUSDC already seeded, skipping'); + return; + } + + // Set up token definition + const docId = tokenDocId(tokenId); + _syncServer!.changeDoc(docId, 'define cUSDC token', (d) => { + d.token.id = 'cusdc'; + d.token.name = 'CRDT USDC'; + d.token.symbol = 'cUSDC'; + d.token.decimals = 6; + d.token.description = 'CRDT-native stablecoin pegged to USDC, stored as an Automerge document'; + d.token.icon = '💵'; + d.token.color = '#2775ca'; + d.token.createdAt = Date.now(); + d.token.createdBy = 'system'; + }); + + // Look up jeff's DID + const jeff = await getUserByUsername('jeff'); + if (!jeff || !jeff.did) { + console.warn('[TokenService] Could not find user "jeff" — skipping mint'); + return; + } + + // Mint 5 cUSDC (5 × 10^6 base units) + const success = mintTokens(tokenId, jeff.did, 'jeff', 5_000_000, 'Initial seed mint', 'system'); + if (success) { + console.log(`[TokenService] cUSDC seeded: 5 cUSDC minted to jeff (${jeff.did})`); + } else { + console.error('[TokenService] Failed to mint cUSDC to jeff'); + } +}