rspace-online/server/token-service.ts

317 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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';
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;
}
/** Transfer tokens between DIDs. */
export function transferTokens(
tokenId: string,
fromDid: string,
fromLabel: string,
toDid: string,
toLabel: string,
amount: number,
memo: string,
issuedBy: string,
timestamp?: number,
): boolean {
const docId = tokenDocId(tokenId);
ensureTokenDoc(tokenId);
const entryId = `xfer-${timestamp || Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const result = _syncServer!.changeDoc<TokenLedgerDoc>(docId, `transfer ${amount} from ${fromLabel} to ${toLabel}`, (d) => {
d.entries[entryId] = {
id: entryId,
to: toDid,
toLabel,
amount,
memo,
type: 'transfer',
from: fromDid,
timestamp: timestamp || Date.now(),
issuedBy,
};
});
return result !== null;
}
/** Get all ledger entries across all tokens, optionally filtered by DID. */
export function getAllTransfers(filterDid?: string): Array<LedgerEntry & { tokenId: string; tokenSymbol: string; tokenDecimals: number; tokenIcon: string }> {
const docIds = listTokenDocs();
const entries: Array<LedgerEntry & { tokenId: string; tokenSymbol: string; tokenDecimals: number; tokenIcon: string }> = [];
for (const docId of docIds) {
const tokenId = docId.replace('global:tokens:ledgers:', '');
const doc = getTokenDoc(tokenId);
if (!doc || !doc.token.name) continue;
for (const entry of Object.values(doc.entries)) {
if (filterDid && entry.to !== filterDid && entry.from !== filterDid) continue;
entries.push({
...entry,
tokenId: doc.token.id,
tokenSymbol: doc.token.symbol,
tokenDecimals: doc.token.decimals,
tokenIcon: doc.token.icon,
});
}
}
return entries.sort((a, b) => a.timestamp - b.timestamp);
}
/** Mint tokens to a DID. */
export function mintTokens(
tokenId: string,
toDid: string,
toLabel: string,
amount: number,
memo: string,
issuedBy: string,
timestamp?: number,
): boolean {
const docId = tokenDocId(tokenId);
ensureTokenDoc(tokenId);
const entryId = `mint-${timestamp || 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: 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 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) {
console.log('[TokenService] DAO ecosystem already seeded, skipping');
return;
}
// ── DAO participants ──
const jeffDid = process.env.SEED_JEFF_DID || 'did:key:jAV6y4tg8UbKJEkN0npvX8CTdJkSCGpU';
const treasuryDid = 'did:key:treasury-main-rspace-dao-2026';
const grantsDid = 'did:key:treasury-grants-committee-2026';
const devFundDid = 'did:key:treasury-dev-fund-rspace-2026';
const aliceDid = 'did:key:alice-contributor-rspace-2026';
const bobDid = 'did:key:bob-auditor-rspace-2026';
const carolDid = 'did:key:carol-designer-rspace-2026';
// Timestamps spread over the last 60 days
const now = Date.now();
const day = 86400000;
const t = (daysAgo: number) => now - daysAgo * day;
// ── 1. cUSDC — CRDT stablecoin ──
ensureTokenDef('cusdc', {
name: 'CRDT USDC', symbol: 'cUSDC', decimals: 6,
description: 'CRDT-native stablecoin pegged to USDC, stored as an Automerge document',
icon: '💵', color: '#2775ca',
});
// Mint to main treasury
mintTokens('cusdc', treasuryDid, 'DAO Treasury', 500_000_000_000, 'Genesis allocation — 500K cUSDC', 'system', t(58));
// Treasury funds grants committee
transferTokens('cusdc', treasuryDid, 'DAO Treasury', grantsDid, 'Grants Committee', 100_000_000_000, 'Q1 2026 grants budget', 'system', t(55));
// Treasury funds dev fund
transferTokens('cusdc', treasuryDid, 'DAO Treasury', devFundDid, 'Dev Fund', 75_000_000_000, 'Q1 2026 development budget', 'system', t(54));
// Grants committee pays contributors
transferTokens('cusdc', grantsDid, 'Grants Committee', aliceDid, 'Alice', 15_000_000_000, 'Grant: local-first sync engine', 'system', t(48));
transferTokens('cusdc', grantsDid, 'Grants Committee', bobDid, 'Bob', 8_000_000_000, 'Grant: security audit Q1', 'system', t(45));
transferTokens('cusdc', grantsDid, 'Grants Committee', carolDid, 'Carol', 12_000_000_000, 'Grant: UX redesign sprint', 'system', t(42));
// Dev fund pays jeff
transferTokens('cusdc', devFundDid, 'Dev Fund', jeffDid, 'jeff', 25_000_000_000, 'Core dev compensation — Jan', 'system', t(40));
transferTokens('cusdc', devFundDid, 'Dev Fund', aliceDid, 'Alice', 10_000_000_000, 'CRDT module development', 'system', t(35));
// Second round
transferTokens('cusdc', grantsDid, 'Grants Committee', bobDid, 'Bob', 5_000_000_000, 'Follow-up audit: identity module', 'system', t(30));
transferTokens('cusdc', devFundDid, 'Dev Fund', jeffDid, 'jeff', 25_000_000_000, 'Core dev compensation — Feb', 'system', t(25));
transferTokens('cusdc', treasuryDid, 'DAO Treasury', grantsDid, 'Grants Committee', 50_000_000_000, 'Grants top-up — mid-quarter', 'system', t(22));
transferTokens('cusdc', grantsDid, 'Grants Committee', carolDid, 'Carol', 8_000_000_000, 'Design system components', 'system', t(18));
// Recent activity
transferTokens('cusdc', devFundDid, 'Dev Fund', jeffDid, 'jeff', 25_000_000_000, 'Core dev compensation — Mar', 'system', t(10));
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',
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)
// Genesis mint to treasury
mintTokens('bft', treasuryDid, 'DAO Treasury', bft(10_000_000), 'Genesis: 10M BFT', '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));
// 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));
// 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));
// Recent
transferTokens('bft', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', bft(500_000), 'Founder vesting — month 3', 'system', t(2));
console.log('[TokenService] DAO ecosystem seeded: cUSDC + BFT tokens with treasury flows');
}
/** Helper to define a token if not yet defined. */
function ensureTokenDef(tokenId: string, def: { name: string; symbol: string; decimals: number; description: string; icon: string; color: string }) {
const doc = ensureTokenDoc(tokenId);
if (doc.token.name) return; // Already defined
const docId = tokenDocId(tokenId);
_syncServer!.changeDoc<TokenLedgerDoc>(docId, `define ${def.symbol} token`, (d) => {
d.token.id = tokenId;
d.token.name = def.name;
d.token.symbol = def.symbol;
d.token.decimals = def.decimals;
d.token.description = def.description;
d.token.icon = def.icon;
d.token.color = def.color;
d.token.createdAt = Date.now();
d.token.createdBy = 'system';
});
}
/** Mint $5 fUSDC welcome balance for a new user. */
export function mintWelcomeBalance(did: string, username: string): boolean {
if (!_syncServer) {
console.warn('[TokenService] SyncServer not initialized, skipping welcome balance');
return false;
}
const tokenId = 'fusdc';
const docId = tokenDocId(tokenId);
let doc = _syncServer.getDoc<TokenLedgerDoc>(docId);
// Create fUSDC token if it doesn't exist
if (!doc) {
doc = Automerge.change(Automerge.init<TokenLedgerDoc>(), 'init fUSDC ledger', (d) => {
const init = tokenLedgerSchema.init();
Object.assign(d, init);
});
_syncServer.setDoc(docId, doc);
}
if (!doc.token.name) {
_syncServer.changeDoc<TokenLedgerDoc>(docId, 'define fUSDC token', (d) => {
d.token.id = 'fusdc';
d.token.name = 'Fake USDC';
d.token.symbol = 'fUSDC';
d.token.decimals = 6;
d.token.description = 'Test stablecoin for new users — $5 welcome balance';
d.token.icon = '💵';
d.token.color = '#2775ca';
d.token.createdAt = Date.now();
d.token.createdBy = 'system';
});
}
// Mint 5 fUSDC (5 × 10^6 base units at 6 decimals)
const success = mintTokens(tokenId, did, username, 5_000_000, 'Welcome balance', 'system');
if (success) {
console.log(`[TokenService] Welcome balance: 5 fUSDC minted to ${username} (${did})`);
}
return success;
}