feat(rwallet): add cUSDC CRDT token system with Automerge ledger

Introduces CRDT-native token infrastructure stored as Automerge documents.
Seeds 5 cUSDC to user jeff on startup. Adds token API routes and a
"Local Tokens" section in the rWallet viewer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 21:43:49 -07:00
parent 347ba73942
commit ba3a0018ea
5 changed files with 411 additions and 1 deletions

View File

@ -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<string, any> | 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 `<div class="local-tokens-section"><span class="spinner"></span> Loading local tokens...</div>`;
}
if (this.crdtBalances.length === 0) return "";
const rows = this.crdtBalances.map((t) => {
const formatted = (t.balance / Math.pow(10, t.decimals)).toFixed(2);
return `<tr>
<td style="display:flex;align-items:center;gap:6px">
<span style="font-size:1.2em">${t.icon || '🪙'}</span>
<strong>${this.esc(t.symbol)}</strong>
</td>
<td>${this.esc(t.name)}</td>
<td style="text-align:right;font-family:monospace;font-weight:600">${formatted}</td>
</tr>`;
}).join('');
return `
<div class="local-tokens-section">
<h3 style="margin:0 0 8px;font-size:1rem;color:#e0e0e0">Local Tokens (CRDT)</h3>
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="color:#999;font-size:0.8rem;text-transform:uppercase">
<th style="text-align:left;padding:4px 8px">Token</th>
<th style="text-align:left;padding:4px 8px">Name</th>
<th style="text-align:right;padding:4px 8px">Balance</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
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 ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
${this.renderLocalTokens()}
${this.renderFeatures()}
${this.renderExamples()}
${this.renderDashboard()}

View File

@ -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";

View File

@ -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));

83
server/token-schemas.ts Normal file
View File

@ -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<string, LedgerEntry>;
}
// ── Schema registration ──
export const tokenLedgerSchema: DocSchema<TokenLedgerDoc> = {
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;
}

155
server/token-service.ts Normal file
View File

@ -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<TokenLedgerDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<TokenLedgerDoc>(), '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<string, { did: string; label: string; balance: number }> {
const holders: Record<string, { did: string; label: string; balance: number }> = {};
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<TokenLedgerDoc>(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<TokenLedgerDoc>(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<TokenLedgerDoc>(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');
}
}