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:
parent
347ba73942
commit
ba3a0018ea
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue