/** * @ruvector/edge-net Credit System MVP * * Simple credit accounting for distributed task execution: * - Nodes earn credits when executing tasks for others * - Nodes spend credits when submitting tasks * - Credits stored in CRDT ledger for conflict-free replication * - Persisted to Firebase for cross-session continuity * * @module @ruvector/edge-net/credits */ import { EventEmitter } from 'events'; import { Ledger } from './ledger.js'; // ============================================ // CREDIT CONFIGURATION // ============================================ /** * Default credit values for operations */ export const CREDIT_CONFIG = { // Base credit cost per task submission taskSubmissionCost: 1, // Credits earned per task completion (base rate) taskCompletionReward: 1, // Multipliers for task types taskTypeMultipliers: { embed: 1.0, process: 1.0, analyze: 1.5, transform: 1.0, compute: 2.0, aggregate: 1.5, custom: 1.0, }, // Priority multipliers (higher priority = higher cost/reward) priorityMultipliers: { low: 0.5, medium: 1.0, high: 1.5, critical: 2.0, }, // Initial credits for new nodes (bootstrap) initialCredits: 10, // Minimum balance required to submit tasks (0 = no minimum) minimumBalance: 0, // Maximum transaction history to keep per node maxTransactionHistory: 1000, }; // ============================================ // CREDIT SYSTEM // ============================================ /** * CreditSystem - Manages credit accounting for distributed task execution * * Integrates with: * - Ledger (CRDT) for conflict-free credit tracking * - TaskExecutionHandler for automatic credit operations * - FirebaseLedgerSync for persistence */ export class CreditSystem extends EventEmitter { /** * @param {Object} options * @param {string} options.nodeId - This node's identifier * @param {Ledger} options.ledger - CRDT ledger instance (will create if not provided) * @param {Object} options.config - Credit configuration overrides */ constructor(options = {}) { super(); this.nodeId = options.nodeId; this.config = { ...CREDIT_CONFIG, ...options.config }; // Use provided ledger or create new one this.ledger = options.ledger || new Ledger({ nodeId: this.nodeId, maxTransactions: this.config.maxTransactionHistory, }); // Transaction tracking by taskId (for deduplication) this.processedTasks = new Map(); // taskId -> { type, timestamp } // Stats this.stats = { creditsEarned: 0, creditsSpent: 0, tasksExecuted: 0, tasksSubmitted: 0, insufficientFunds: 0, }; this.initialized = false; } /** * Initialize credit system */ async initialize() { // Initialize ledger if (!this.ledger.initialized) { await this.ledger.initialize(); } // Grant initial credits if balance is zero (new node) if (this.ledger.balance() === 0 && this.config.initialCredits > 0) { this.ledger.credit(this.config.initialCredits, 'Initial bootstrap credits'); console.log(`[Credits] Granted ${this.config.initialCredits} initial credits`); } this.initialized = true; this.emit('initialized', { balance: this.getBalance() }); return this; } // ============================================ // CREDIT OPERATIONS // ============================================ /** * Earn credits when completing a task for another node * * @param {string} nodeId - The node that earned credits (usually this node) * @param {number} amount - Credit amount (will be adjusted by multipliers) * @param {string} taskId - Task identifier * @param {Object} taskInfo - Task details for calculating multipliers * @returns {Object} Transaction record */ earnCredits(nodeId, amount, taskId, taskInfo = {}) { // Only process for this node if (nodeId !== this.nodeId) { console.warn(`[Credits] Ignoring earnCredits for different node: ${nodeId}`); return null; } // Check for duplicate processing if (this.processedTasks.has(`earn:${taskId}`)) { console.warn(`[Credits] Task ${taskId} already credited`); return null; } // Calculate final amount with multipliers const finalAmount = this._calculateAmount(amount, taskInfo); // Record transaction in ledger const tx = this.ledger.credit(finalAmount, JSON.stringify({ taskId, type: 'task_completion', taskType: taskInfo.type, submitter: taskInfo.submitter, })); // Mark as processed this.processedTasks.set(`earn:${taskId}`, { type: 'earn', amount: finalAmount, timestamp: Date.now(), }); // Update stats this.stats.creditsEarned += finalAmount; this.stats.tasksExecuted++; // Prune old processed tasks (keep last 10000) this._pruneProcessedTasks(); this.emit('credits-earned', { nodeId, amount: finalAmount, taskId, balance: this.getBalance(), tx, }); console.log(`[Credits] Earned ${finalAmount} credits for task ${taskId.slice(0, 8)}...`); return tx; } /** * Spend credits when submitting a task * * @param {string} nodeId - The node spending credits (usually this node) * @param {number} amount - Credit amount (will be adjusted by multipliers) * @param {string} taskId - Task identifier * @param {Object} taskInfo - Task details for calculating cost * @returns {Object|null} Transaction record or null if insufficient funds */ spendCredits(nodeId, amount, taskId, taskInfo = {}) { // Only process for this node if (nodeId !== this.nodeId) { console.warn(`[Credits] Ignoring spendCredits for different node: ${nodeId}`); return null; } // Check for duplicate processing if (this.processedTasks.has(`spend:${taskId}`)) { console.warn(`[Credits] Task ${taskId} already charged`); return null; } // Calculate final amount with multipliers const finalAmount = this._calculateAmount(amount, taskInfo); // Check balance const balance = this.getBalance(); if (balance < finalAmount) { this.stats.insufficientFunds++; this.emit('insufficient-funds', { nodeId, required: finalAmount, available: balance, taskId, }); // In MVP, we allow tasks even with insufficient funds // (can be enforced later) if (this.config.minimumBalance > 0 && balance < this.config.minimumBalance) { console.warn(`[Credits] Insufficient funds: ${balance} < ${finalAmount}`); return null; } } // Record transaction in ledger let tx; try { tx = this.ledger.debit(finalAmount, JSON.stringify({ taskId, type: 'task_submission', taskType: taskInfo.type, targetPeer: taskInfo.targetPeer, })); } catch (error) { // Debit failed (insufficient balance in strict mode) console.warn(`[Credits] Debit failed: ${error.message}`); return null; } // Mark as processed this.processedTasks.set(`spend:${taskId}`, { type: 'spend', amount: finalAmount, timestamp: Date.now(), }); // Update stats this.stats.creditsSpent += finalAmount; this.stats.tasksSubmitted++; this._pruneProcessedTasks(); this.emit('credits-spent', { nodeId, amount: finalAmount, taskId, balance: this.getBalance(), tx, }); console.log(`[Credits] Spent ${finalAmount} credits for task ${taskId.slice(0, 8)}...`); return tx; } /** * Get current credit balance * * @param {string} nodeId - Node to check (defaults to this node) * @returns {number} Current balance */ getBalance(nodeId = null) { // For MVP, only track this node's balance if (nodeId && nodeId !== this.nodeId) { // Would need network query for other nodes return 0; } return this.ledger.balance(); } /** * Get transaction history * * @param {string} nodeId - Node to get history for (defaults to this node) * @param {number} limit - Maximum transactions to return * @returns {Array} Transaction history */ getTransactionHistory(nodeId = null, limit = 50) { // For MVP, only track this node's history if (nodeId && nodeId !== this.nodeId) { return []; } const transactions = this.ledger.getTransactions(limit); // Parse memo JSON and add readable info return transactions.map(tx => { let details = {}; try { details = JSON.parse(tx.memo || '{}'); } catch { details = { memo: tx.memo }; } return { id: tx.id, type: tx.type, // 'credit' or 'debit' amount: tx.amount, timestamp: tx.timestamp, date: new Date(tx.timestamp).toISOString(), ...details, }; }); } /** * Check if node has sufficient credits for a task * * @param {number} amount - Base amount * @param {Object} taskInfo - Task info for multipliers * @returns {boolean} True if sufficient */ hasSufficientCredits(amount, taskInfo = {}) { const required = this._calculateAmount(amount, taskInfo); return this.getBalance() >= required; } // ============================================ // CALCULATION HELPERS // ============================================ /** * Calculate final credit amount with multipliers */ _calculateAmount(baseAmount, taskInfo = {}) { let amount = baseAmount; // Apply task type multiplier if (taskInfo.type && this.config.taskTypeMultipliers[taskInfo.type]) { amount *= this.config.taskTypeMultipliers[taskInfo.type]; } // Apply priority multiplier if (taskInfo.priority && this.config.priorityMultipliers[taskInfo.priority]) { amount *= this.config.priorityMultipliers[taskInfo.priority]; } // Round to 2 decimal places return Math.round(amount * 100) / 100; } /** * Prune old processed task records */ _pruneProcessedTasks() { if (this.processedTasks.size > 10000) { // Remove oldest entries const entries = Array.from(this.processedTasks.entries()) .sort((a, b) => a[1].timestamp - b[1].timestamp); const toRemove = entries.slice(0, 5000); for (const [key] of toRemove) { this.processedTasks.delete(key); } } } // ============================================ // INTEGRATION METHODS // ============================================ /** * Wire to TaskExecutionHandler for automatic credit operations * * @param {TaskExecutionHandler} handler - Task execution handler */ wireToTaskHandler(handler) { // Auto-credit when we complete a task handler.on('task-complete', ({ taskId, from, duration, result }) => { this.earnCredits( this.nodeId, this.config.taskCompletionReward, taskId, { type: result?.taskType || 'compute', submitter: from, duration, } ); }); // Could also track task submissions if handler emits that event handler.on('task-submitted', ({ taskId, to, task }) => { this.spendCredits( this.nodeId, this.config.taskSubmissionCost, taskId, { type: task?.type || 'compute', priority: task?.priority, targetPeer: to, } ); }); console.log('[Credits] Wired to TaskExecutionHandler'); } /** * Get credit system summary */ getSummary() { return { nodeId: this.nodeId, balance: this.getBalance(), totalEarned: this.ledger.totalEarned(), totalSpent: this.ledger.totalSpent(), stats: { ...this.stats }, initialized: this.initialized, recentTransactions: this.getTransactionHistory(null, 5), }; } /** * Export ledger state for sync */ export() { return this.ledger.export(); } /** * Merge with remote ledger state (CRDT) */ merge(remoteState) { this.ledger.merge(remoteState); this.emit('merged', { balance: this.getBalance() }); } /** * Shutdown credit system */ async shutdown() { await this.ledger.shutdown(); this.initialized = false; this.emit('shutdown'); } } // ============================================ // FIREBASE CREDIT SYNC // ============================================ /** * Syncs credits to Firebase for persistence and cross-node visibility */ export class FirebaseCreditSync extends EventEmitter { /** * @param {CreditSystem} creditSystem - Credit system to sync * @param {Object} options * @param {Object} options.firebaseConfig - Firebase configuration * @param {number} options.syncInterval - Sync interval in ms */ constructor(creditSystem, options = {}) { super(); this.credits = creditSystem; this.config = options.firebaseConfig; this.syncInterval = options.syncInterval || 30000; // Firebase instances this.db = null; this.firebase = null; this.syncTimer = null; this.unsubscribers = []; } /** * Start Firebase sync */ async start() { if (!this.config || !this.config.apiKey || !this.config.projectId) { console.log('[FirebaseCreditSync] No Firebase config, skipping sync'); return false; } try { const { initializeApp, getApps } = await import('firebase/app'); const { getFirestore, doc, setDoc, onSnapshot, getDoc, collection } = await import('firebase/firestore'); this.firebase = { doc, setDoc, onSnapshot, getDoc, collection }; const apps = getApps(); const app = apps.length ? apps[0] : initializeApp(this.config); this.db = getFirestore(app); // Initial sync await this.pull(); // Subscribe to updates this.subscribe(); // Periodic push this.syncTimer = setInterval(() => this.push(), this.syncInterval); console.log('[FirebaseCreditSync] Started'); return true; } catch (error) { console.log('[FirebaseCreditSync] Failed to start:', error.message); return false; } } /** * Pull credit state from Firebase */ async pull() { const { doc, getDoc } = this.firebase; const creditRef = doc(this.db, 'edgenet_credits', this.credits.nodeId); const snapshot = await getDoc(creditRef); if (snapshot.exists()) { const remoteState = snapshot.data(); if (remoteState.ledgerState) { this.credits.merge(remoteState.ledgerState); } } } /** * Push credit state to Firebase */ async push() { const { doc, setDoc } = this.firebase; const creditRef = doc(this.db, 'edgenet_credits', this.credits.nodeId); await setDoc(creditRef, { nodeId: this.credits.nodeId, balance: this.credits.getBalance(), totalEarned: this.credits.ledger.totalEarned(), totalSpent: this.credits.ledger.totalSpent(), ledgerState: this.credits.export(), updatedAt: Date.now(), }, { merge: true }); } /** * Subscribe to credit updates from Firebase */ subscribe() { const { doc, onSnapshot } = this.firebase; const creditRef = doc(this.db, 'edgenet_credits', this.credits.nodeId); const unsubscribe = onSnapshot(creditRef, (snapshot) => { if (snapshot.exists()) { const data = snapshot.data(); if (data.ledgerState) { this.credits.merge(data.ledgerState); } } }); this.unsubscribers.push(unsubscribe); } /** * Stop sync */ stop() { if (this.syncTimer) { clearInterval(this.syncTimer); this.syncTimer = null; } for (const unsub of this.unsubscribers) { if (typeof unsub === 'function') unsub(); } this.unsubscribers = []; } } // ============================================ // CONVENIENCE FACTORY // ============================================ /** * Create and initialize a complete credit system with optional Firebase sync * * @param {Object} options * @param {string} options.nodeId - Node identifier * @param {Ledger} options.ledger - Existing ledger (optional) * @param {Object} options.firebaseConfig - Firebase config for sync * @param {Object} options.config - Credit configuration overrides * @returns {Promise} Initialized credit system */ export async function createCreditSystem(options = {}) { const system = new CreditSystem(options); await system.initialize(); // Start Firebase sync if configured if (options.firebaseConfig) { const sync = new FirebaseCreditSync(system, { firebaseConfig: options.firebaseConfig, syncInterval: options.syncInterval, }); await sync.start(); // Attach sync to system for cleanup system._firebaseSync = sync; } return system; } // ============================================ // EXPORTS // ============================================ export default CreditSystem;