153 lines
5.0 KiB
TypeScript
153 lines
5.0 KiB
TypeScript
/**
|
||
* 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');
|
||
}
|
||
}
|