Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
631
vendor/ruvector/examples/edge-net/pkg/credits.js
vendored
Normal file
631
vendor/ruvector/examples/edge-net/pkg/credits.js
vendored
Normal file
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* @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<CreditSystem>} 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;
|
||||
Reference in New Issue
Block a user