476 lines
18 KiB
TypeScript
476 lines
18 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. Excludes reversed burns. */
|
||
export function getBalance(doc: TokenLedgerDoc, did: string): number {
|
||
let balance = 0;
|
||
for (const entry of Object.values(doc.entries)) {
|
||
// Skip reversed burns (the compensating mint handles the refund)
|
||
if (entry.type === 'burn' && (entry as any).status === 'reversed') continue;
|
||
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,
|
||
extra?: { txHash?: string; onChainNetwork?: string },
|
||
): 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,
|
||
};
|
||
if (extra?.txHash) d.entries[entryId].txHash = extra.txHash;
|
||
if (extra?.onChainNetwork) d.entries[entryId].onChainNetwork = extra.onChainNetwork;
|
||
d.token.totalSupply = (d.token.totalSupply || 0) + amount;
|
||
});
|
||
return result !== null;
|
||
}
|
||
|
||
/**
|
||
* Mint cUSDC from an on-chain USDC payment (Direction 1: x402 → CRDT).
|
||
* Idempotent — skips if an entry with the same txHash already exists.
|
||
*/
|
||
export function mintFromOnChain(
|
||
did: string,
|
||
label: string,
|
||
amountDecimal: string,
|
||
txHash: string,
|
||
network: string,
|
||
): boolean {
|
||
const tokenId = 'cusdc';
|
||
const doc = ensureTokenDoc(tokenId);
|
||
|
||
// Idempotency: check if txHash already minted
|
||
for (const entry of Object.values(doc.entries)) {
|
||
if (entry.txHash === txHash) {
|
||
console.log(`[TokenService] x402 bridge: txHash ${txHash} already minted, skipping`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Convert decimal USDC string to cUSDC base units (6 decimals)
|
||
const amount = Math.round(parseFloat(amountDecimal) * 1_000_000);
|
||
if (amount <= 0 || isNaN(amount)) {
|
||
console.warn(`[TokenService] x402 bridge: invalid amount "${amountDecimal}"`);
|
||
return false;
|
||
}
|
||
|
||
const success = mintTokens(
|
||
tokenId, did, label, amount,
|
||
`x402 bridge: on-chain USDC → cUSDC (tx: ${txHash.slice(0, 10)}...)`,
|
||
'x402-bridge',
|
||
undefined,
|
||
{ txHash, onChainNetwork: network },
|
||
);
|
||
|
||
if (success) {
|
||
console.log(`[TokenService] x402 bridge: minted ${amount} cUSDC to ${label} (${did}) from tx ${txHash.slice(0, 10)}...`);
|
||
}
|
||
return success;
|
||
}
|
||
|
||
/** Burn tokens from a DID. */
|
||
export function burnTokens(
|
||
tokenId: string,
|
||
fromDid: string,
|
||
fromLabel: string,
|
||
amount: number,
|
||
memo: string,
|
||
issuedBy: string,
|
||
timestamp?: number,
|
||
): boolean {
|
||
const docId = tokenDocId(tokenId);
|
||
ensureTokenDoc(tokenId);
|
||
|
||
const entryId = `burn-${timestamp || Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
const result = _syncServer!.changeDoc<TokenLedgerDoc>(docId, `burn ${amount} from ${fromLabel}`, (d) => {
|
||
d.entries[entryId] = {
|
||
id: entryId,
|
||
to: '',
|
||
toLabel: '',
|
||
amount,
|
||
memo,
|
||
type: 'burn',
|
||
from: fromDid,
|
||
timestamp: timestamp || Date.now(),
|
||
issuedBy,
|
||
};
|
||
d.token.totalSupply = Math.max(0, (d.token.totalSupply || 0) - amount);
|
||
});
|
||
return result !== null;
|
||
}
|
||
|
||
/**
|
||
* Burn tokens into escrow (Phase 4 off-ramp).
|
||
* Creates a burn entry with offRampId + status:'escrow'. Balance reduced immediately.
|
||
*/
|
||
export function burnTokensEscrow(
|
||
tokenId: string,
|
||
fromDid: string,
|
||
fromLabel: string,
|
||
amount: number,
|
||
offRampId: string,
|
||
memo: string,
|
||
): boolean {
|
||
const docId = tokenDocId(tokenId);
|
||
ensureTokenDoc(tokenId);
|
||
|
||
const entryId = `escrow-${offRampId}`;
|
||
const result = _syncServer!.changeDoc<TokenLedgerDoc>(docId, `escrow burn ${amount} from ${fromLabel}`, (d) => {
|
||
d.entries[entryId] = {
|
||
id: entryId,
|
||
to: '',
|
||
toLabel: '',
|
||
amount,
|
||
memo,
|
||
type: 'burn',
|
||
from: fromDid,
|
||
timestamp: Date.now(),
|
||
issuedBy: 'offramp-service',
|
||
offRampId,
|
||
status: 'escrow',
|
||
} as any; // extended fields
|
||
d.token.totalSupply = Math.max(0, (d.token.totalSupply || 0) - amount);
|
||
});
|
||
return result !== null;
|
||
}
|
||
|
||
/** Confirm an escrow burn (payout succeeded). */
|
||
export function confirmBurn(tokenId: string, offRampId: string): boolean {
|
||
const docId = tokenDocId(tokenId);
|
||
const doc = ensureTokenDoc(tokenId);
|
||
const entryId = `escrow-${offRampId}`;
|
||
if (!doc.entries[entryId]) return false;
|
||
|
||
const result = _syncServer!.changeDoc<TokenLedgerDoc>(docId, `confirm escrow ${offRampId}`, (d) => {
|
||
if (d.entries[entryId]) {
|
||
(d.entries[entryId] as any).status = 'confirmed';
|
||
}
|
||
});
|
||
return result !== null;
|
||
}
|
||
|
||
/** Reverse an escrow burn (payout failed) — mints tokens back to the original holder. */
|
||
export function reverseBurn(tokenId: string, offRampId: string): boolean {
|
||
const docId = tokenDocId(tokenId);
|
||
const doc = ensureTokenDoc(tokenId);
|
||
const entryId = `escrow-${offRampId}`;
|
||
const entry = doc.entries[entryId];
|
||
if (!entry) return false;
|
||
|
||
const result = _syncServer!.changeDoc<TokenLedgerDoc>(docId, `reverse escrow ${offRampId}`, (d) => {
|
||
// Mark original burn as reversed
|
||
if (d.entries[entryId]) {
|
||
(d.entries[entryId] as any).status = 'reversed';
|
||
}
|
||
// Compensating mint to refund the holder
|
||
const refundId = `refund-${offRampId}`;
|
||
d.entries[refundId] = {
|
||
id: refundId,
|
||
to: entry.from,
|
||
toLabel: '',
|
||
amount: entry.amount,
|
||
memo: `Refund: off-ramp ${offRampId} reversed`,
|
||
type: 'mint',
|
||
from: '',
|
||
timestamp: Date.now(),
|
||
issuedBy: 'offramp-service',
|
||
};
|
||
d.token.totalSupply = (d.token.totalSupply || 0) + entry.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 $MYCO token (last one created)
|
||
const mycoDoc = getTokenDoc('myco');
|
||
if (mycoDoc && mycoDoc.token.name && Object.keys(mycoDoc.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. $MYCO — Governance token (bonding curve) ──
|
||
ensureTokenDef('myco', {
|
||
name: 'MYCO Token', symbol: '$MYCO', decimals: 6,
|
||
description: 'Governance token for the rSpace DAO — bonding curve with cUSDC reserve, used for voting and delegation',
|
||
icon: '🌱', color: '#22c55e',
|
||
});
|
||
|
||
const myco = (n: number) => n * 1_000_000; // 6 effective decimals
|
||
|
||
// Genesis mint to treasury
|
||
mintTokens('myco', treasuryDid, 'DAO Treasury', myco(10_000_000), 'Genesis: 10M $MYCO', 'system', t(58));
|
||
// Distribute to sub-treasuries
|
||
transferTokens('myco', treasuryDid, 'DAO Treasury', grantsDid, 'Grants Committee', myco(2_000_000), 'Grants committee allocation', 'system', t(56));
|
||
transferTokens('myco', treasuryDid, 'DAO Treasury', devFundDid, 'Dev Fund', myco(1_500_000), 'Dev fund allocation', 'system', t(56));
|
||
// Vest to contributors
|
||
transferTokens('myco', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', myco(500_000), 'Founder vesting — month 1', 'system', t(50));
|
||
transferTokens('myco', grantsDid, 'Grants Committee', aliceDid, 'Alice', myco(100_000), 'Contributor reward: sync engine', 'system', t(46));
|
||
transferTokens('myco', grantsDid, 'Grants Committee', bobDid, 'Bob', myco(50_000), 'Contributor reward: audit', 'system', t(43));
|
||
transferTokens('myco', grantsDid, 'Grants Committee', carolDid, 'Carol', myco(75_000), 'Contributor reward: UX', 'system', t(40));
|
||
transferTokens('myco', devFundDid, 'Dev Fund', jeffDid, 'jeff', myco(200_000), 'Dev milestone: rwallet launch', 'system', t(35));
|
||
// Second vesting round
|
||
transferTokens('myco', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', myco(500_000), 'Founder vesting — month 2', 'system', t(20));
|
||
transferTokens('myco', grantsDid, 'Grants Committee', aliceDid, 'Alice', myco(150_000), 'Grant reward: rMaps', 'system', t(15));
|
||
transferTokens('myco', devFundDid, 'Dev Fund', bobDid, 'Bob', myco(80_000), 'Security bounty payout', 'system', t(12));
|
||
transferTokens('myco', devFundDid, 'Dev Fund', carolDid, 'Carol', myco(60_000), 'Design system completion', 'system', t(8));
|
||
// Recent
|
||
transferTokens('myco', treasuryDid, 'DAO Treasury', jeffDid, 'jeff', myco(500_000), 'Founder vesting — month 3', 'system', t(2));
|
||
|
||
console.log('[TokenService] DAO ecosystem seeded: cUSDC + $MYCO 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;
|
||
}
|