rspace-online/server/token-service.ts

153 lines
5.0 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;
}
/** 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 fully seeded (has entries)
if (doc.token.name && Object.keys(doc.entries).length > 0) {
console.log('[TokenService] cUSDC already seeded, skipping');
return;
}
// Set up token definition (idempotent — only writes if not yet defined)
const docId = tokenDocId(tokenId);
if (!doc.token.name) {
_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';
});
}
// Resolve jeff's DID — use known DID with env override support
const jeffDid = process.env.SEED_JEFF_DID || 'did:key:jAV6y4tg8UbKJEkN0npvX8CTdJkSCGpU';
// Mint 5 cUSDC (5 × 10^6 base units)
const success = mintTokens(tokenId, jeffDid, 'jeff', 5_000_000, 'Initial seed mint', 'system');
if (success) {
console.log(`[TokenService] cUSDC seeded: 5 cUSDC minted to jeff (${jeffDid})`);
} else {
console.error('[TokenService] Failed to mint cUSDC to jeff');
}
}