UNPKG

@venly/wallet-mcp

Version:

Production-ready MCP server enabling AI agents to perform Web3 wallet operations through Venly's blockchain infrastructure. Supports 14+ networks including Ethereum, Polygon, Arbitrum, and stablecoin operations.

637 lines 24 kB
/** * Webhook Event Processor * * Handles webhook event processing, filtering, and insights generation * for the Venly MCP Server real-time event system */ import crypto from 'crypto'; import winston from 'winston'; import { WebhookEventType } from '../types/venly.js'; /** * Main webhook event processor class */ export class WebhookEventProcessor { logger; activeWebhooks = new Map(); eventQueue = []; processingStats = { totalProcessed: 0, successfullyProcessed: 0, filtered: 0, errors: 0, averageProcessingTime: 0 }; eventHistory = []; maxEventHistory = 1000; // Keep last 1000 events for insights maxQueueSize = 500; // Maximum queue size constructor() { this.logger = this.createLogger(); this.logger.info('WebhookEventProcessor initialized'); } // ============================================================================ // Webhook Management // ============================================================================ /** * Register a webhook configuration */ registerWebhook(config) { this.activeWebhooks.set(config.id, config); this.logger.info('Webhook registered', { webhookId: config.id, walletId: config.walletId, eventTypes: config.eventTypes, isActive: config.isActive }); } /** * Unregister a webhook configuration */ unregisterWebhook(webhookId) { const removed = this.activeWebhooks.delete(webhookId); if (removed) { this.logger.info('Webhook unregistered', { webhookId }); } return removed; } /** * Get webhook configuration */ getWebhook(webhookId) { return this.activeWebhooks.get(webhookId); } /** * List all active webhooks */ listWebhooks() { return Array.from(this.activeWebhooks.values()); } /** * Update webhook configuration */ updateWebhook(webhookId, updates) { const existing = this.activeWebhooks.get(webhookId); if (!existing) { return false; } const updated = { ...existing, ...updates, id: webhookId, // Ensure ID cannot be changed updatedAt: new Date().toISOString() }; this.activeWebhooks.set(webhookId, updated); this.logger.info('Webhook updated', { webhookId, updates }); return true; } // ============================================================================ // Event Processing // ============================================================================ /** * Process a webhook event */ async processEvent(event) { const startTime = Date.now(); try { this.logger.debug('Processing webhook event', { eventId: event.eventId, eventType: event.eventType, walletId: event.walletId }); // Verify event signature const relevantWebhooks = this.findRelevantWebhooks(event); if (relevantWebhooks.length === 0) { this.logger.debug('No relevant webhooks found for event', { eventId: event.eventId, walletId: event.walletId }); this.processingStats.filtered++; return; } // Process event for each relevant webhook for (const webhook of relevantWebhooks) { await this.processEventForWebhook(event, webhook); } // Add to event history for insights this.addToEventHistory(event); // Update processing statistics const processingTime = Date.now() - startTime; this.updateProcessingStats(processingTime, true); this.logger.debug('Event processed successfully', { eventId: event.eventId, processingTime, webhooksTriggered: relevantWebhooks.length }); } catch (error) { const processingTime = Date.now() - startTime; this.updateProcessingStats(processingTime, false); this.logger.error('Error processing webhook event', { eventId: event.eventId, error: error instanceof Error ? error.message : 'Unknown error', processingTime }); throw error; } } /** * Add event to processing queue */ queueEvent(event) { if (this.eventQueue.length >= this.maxQueueSize) { this.logger.warn('Event queue is full, dropping oldest event', { queueSize: this.eventQueue.length, maxSize: this.maxQueueSize }); this.eventQueue.shift(); // Remove oldest event } this.eventQueue.push(event); this.logger.debug('Event queued for processing', { eventId: event.eventId, queueSize: this.eventQueue.length }); } /** * Process all queued events */ async processQueuedEvents() { const queueSize = this.eventQueue.length; if (queueSize === 0) { return; } this.logger.info('Processing queued events', { queueSize }); const events = this.eventQueue.splice(0); // Remove all events from queue for (const event of events) { try { await this.processEvent(event); } catch (error) { this.logger.error('Error processing queued event', { eventId: event.eventId, error: error instanceof Error ? error.message : 'Unknown error' }); } } this.logger.info('Finished processing queued events', { processed: events.length }); } // ============================================================================ // Event Filtering // ============================================================================ /** * Filter event based on webhook filters */ async filterEvent(event, filters) { try { // Check minimum amount filter if (filters.minimumAmount !== undefined) { const amount = this.extractEventAmount(event); if (amount !== null && amount < filters.minimumAmount) { this.logger.debug('Event filtered by minimum amount', { eventId: event.eventId, amount, minimumAmount: filters.minimumAmount }); return false; } } // Check token addresses filter if (filters.tokenAddresses && filters.tokenAddresses.length > 0) { const tokenAddress = this.extractTokenAddress(event); if (tokenAddress && !filters.tokenAddresses.includes(tokenAddress)) { this.logger.debug('Event filtered by token address', { eventId: event.eventId, tokenAddress, allowedAddresses: filters.tokenAddresses }); return false; } } // Check contract addresses filter if (filters.contractAddresses && filters.contractAddresses.length > 0) { const contractAddress = this.extractContractAddress(event); if (contractAddress && !filters.contractAddresses.includes(contractAddress)) { this.logger.debug('Event filtered by contract address', { eventId: event.eventId, contractAddress, allowedAddresses: filters.contractAddresses }); return false; } } // Check from addresses filter if (filters.fromAddresses && filters.fromAddresses.length > 0) { const fromAddress = this.extractFromAddress(event); if (fromAddress && !filters.fromAddresses.includes(fromAddress)) { this.logger.debug('Event filtered by from address', { eventId: event.eventId, fromAddress, allowedAddresses: filters.fromAddresses }); return false; } } // Check to addresses filter if (filters.toAddresses && filters.toAddresses.length > 0) { const toAddress = this.extractToAddress(event); if (toAddress && !filters.toAddresses.includes(toAddress)) { this.logger.debug('Event filtered by to address', { eventId: event.eventId, toAddress, allowedAddresses: filters.toAddresses }); return false; } } // Check threshold percentage filter (for portfolio events) if (filters.thresholdPercentage !== undefined && event.eventType === WebhookEventType.PORTFOLIO_VALUE_CHANGED) { const portfolioData = event.data; if (portfolioData?.changePercentage !== undefined && Math.abs(portfolioData.changePercentage) < filters.thresholdPercentage) { this.logger.debug('Event filtered by threshold percentage', { eventId: event.eventId, changePercentage: portfolioData?.changePercentage, threshold: filters.thresholdPercentage }); return false; } } return true; } catch (error) { this.logger.error('Error filtering event', { eventId: event.eventId, error: error instanceof Error ? error.message : 'Unknown error' }); return true; // Allow event through if filtering fails } } // ============================================================================ // Insights Generation // ============================================================================ /** * Generate insights from processed events */ async generateInsights(timeRange) { const events = timeRange ? this.filterEventsByTimeRange(timeRange) : this.eventHistory; if (events.length === 0) { return this.createEmptyInsights(timeRange); } const eventsByType = this.groupEventsByType(events); const patterns = this.analyzeEventPatterns(events); const performance = this.calculatePerformanceMetrics(); const insights = { totalEvents: events.length, eventsByType, timeRange: timeRange || { start: events.length > 0 ? events[0].timestamp.toISOString() : new Date().toISOString(), end: events.length > 0 ? events[events.length - 1].timestamp.toISOString() : new Date().toISOString() }, patterns, performance }; this.logger.info('Generated event insights', { totalEvents: insights.totalEvents, timeRange: insights.timeRange, eventTypes: Object.keys(eventsByType).length }); return insights; } // ============================================================================ // Webhook Signature Verification // ============================================================================ /** * Verify webhook signature using HMAC-SHA256 */ verifyWebhookSignature(payload, signature, secret) { try { const expectedSignature = crypto .createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex'); // Use timing-safe comparison to prevent timing attacks const providedSignature = signature.replace('sha256=', ''); return crypto.timingSafeEqual(Buffer.from(expectedSignature, 'hex'), Buffer.from(providedSignature, 'hex')); } catch (error) { this.logger.error('Error verifying webhook signature', { error: error instanceof Error ? error.message : 'Unknown error' }); return false; } } /** * Generate webhook signature for outgoing webhooks */ generateWebhookSignature(payload, secret) { const signature = crypto .createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex'); return `sha256=${signature}`; } // ============================================================================ // Statistics and Monitoring // ============================================================================ /** * Get processing statistics */ getProcessingStats() { return { ...this.processingStats, queueSize: this.eventQueue.length, historySize: this.eventHistory.length }; } /** * Reset processing statistics */ resetProcessingStats() { this.processingStats.totalProcessed = 0; this.processingStats.successfullyProcessed = 0; this.processingStats.filtered = 0; this.processingStats.errors = 0; this.processingStats.averageProcessingTime = 0; this.logger.info('Processing statistics reset'); } /** * Clear event history */ clearEventHistory() { this.eventHistory.length = 0; this.logger.info('Event history cleared'); } // ============================================================================ // Private Helper Methods // ============================================================================ /** * Find webhooks relevant to an event */ findRelevantWebhooks(event) { const relevant = []; for (const webhook of this.activeWebhooks.values()) { if (!webhook.isActive) { continue; } // Check if webhook is for this wallet if (webhook.walletId !== event.walletId) { continue; } // Check if webhook listens for this event type if (!webhook.eventTypes.includes(event.eventType)) { continue; } relevant.push(webhook); } return relevant; } /** * Process event for a specific webhook */ async processEventForWebhook(event, webhook) { // Apply filters if configured if (webhook.filters) { const passesFilter = await this.filterEvent(event, webhook.filters); if (!passesFilter) { this.logger.debug('Event filtered out for webhook', { eventId: event.eventId, webhookId: webhook.id }); return; } } // Verify event signature const payload = JSON.stringify(event); const expectedSignature = this.generateWebhookSignature(payload, webhook.secretKey); if (event.signature !== expectedSignature) { this.logger.warn('Invalid event signature for webhook', { eventId: event.eventId, webhookId: webhook.id }); return; } this.logger.debug('Event processed for webhook', { eventId: event.eventId, webhookId: webhook.id, eventType: event.eventType }); } /** * Add event to history for insights */ addToEventHistory(event) { this.eventHistory.push(event); // Maintain maximum history size if (this.eventHistory.length > this.maxEventHistory) { this.eventHistory.shift(); } } /** * Update processing statistics */ updateProcessingStats(processingTime, success) { this.processingStats.totalProcessed++; if (success) { this.processingStats.successfullyProcessed++; } else { this.processingStats.errors++; } // Update average processing time const totalTime = this.processingStats.averageProcessingTime * (this.processingStats.totalProcessed - 1); this.processingStats.averageProcessingTime = (totalTime + processingTime) / this.processingStats.totalProcessed; } /** * Extract amount from event data */ extractEventAmount(event) { switch (event.eventType) { case WebhookEventType.TRANSACTION_CONFIRMED: case WebhookEventType.TRANSACTION_FAILED: const txData = event.data; return parseFloat(txData.value); case WebhookEventType.BALANCE_CHANGED: const balanceData = event.data; return Math.abs(parseFloat(balanceData.difference)); case WebhookEventType.TOKEN_CREATION_SUCCEEDED: case WebhookEventType.TOKEN_CREATION_FAILED: const tokenData = event.data; return tokenData.amount; default: return null; } } /** * Extract token address from event data */ extractTokenAddress(event) { switch (event.eventType) { case WebhookEventType.TRANSACTION_CONFIRMED: case WebhookEventType.TRANSACTION_FAILED: const txData = event.data; return txData.tokenAddress || null; case WebhookEventType.BALANCE_CHANGED: const balanceData = event.data; return balanceData.tokenAddress || null; case WebhookEventType.TOKEN_CREATION_SUCCEEDED: case WebhookEventType.TOKEN_CREATION_FAILED: const tokenData = event.data; return tokenData.contractAddress; default: return null; } } /** * Extract contract address from event data */ extractContractAddress(event) { switch (event.eventType) { case WebhookEventType.CONTRACT_CREATION_SUCCEEDED: case WebhookEventType.CONTRACT_CREATION_FAILED: const contractData = event.data; return contractData.contractAddress; case WebhookEventType.TOKEN_TYPE_CREATION_SUCCEEDED: case WebhookEventType.TOKEN_TYPE_CREATION_FAILED: case WebhookEventType.TOKEN_CREATION_SUCCEEDED: case WebhookEventType.TOKEN_CREATION_FAILED: const tokenData = event.data; return tokenData.contractAddress; default: return null; } } /** * Extract from address from event data */ extractFromAddress(event) { switch (event.eventType) { case WebhookEventType.TRANSACTION_CONFIRMED: case WebhookEventType.TRANSACTION_FAILED: const txData = event.data; return txData.from; default: return null; } } /** * Extract to address from event data */ extractToAddress(event) { switch (event.eventType) { case WebhookEventType.TRANSACTION_CONFIRMED: case WebhookEventType.TRANSACTION_FAILED: const txData = event.data; return txData.to; case WebhookEventType.TOKEN_CREATION_SUCCEEDED: case WebhookEventType.TOKEN_CREATION_FAILED: const tokenData = event.data; return tokenData.recipient; default: return null; } } /** * Filter events by time range */ filterEventsByTimeRange(timeRange) { const startTime = new Date(timeRange.start); const endTime = new Date(timeRange.end); return this.eventHistory.filter(event => { const eventTime = new Date(event.timestamp); return eventTime >= startTime && eventTime <= endTime; }); } /** * Group events by type for insights */ groupEventsByType(events) { const grouped = {}; for (const event of events) { grouped[event.eventType] = (grouped[event.eventType] || 0) + 1; } return grouped; } /** * Analyze event patterns */ analyzeEventPatterns(events) { if (events.length === 0) { return { mostActiveHour: 0, averageEventsPerDay: 0, peakEventType: WebhookEventType.TRANSACTION_CONFIRMED }; } // Analyze hourly distribution const hourlyDistribution = {}; const eventsByType = this.groupEventsByType(events); for (const event of events) { const hour = new Date(event.timestamp).getHours(); hourlyDistribution[hour] = (hourlyDistribution[hour] || 0) + 1; } const mostActiveHour = Object.entries(hourlyDistribution) .reduce((max, [hour, count]) => count > max.count ? { hour: parseInt(hour), count } : max, { hour: 0, count: 0 }) .hour; // Calculate average events per day const timeSpan = events.length > 1 ? (new Date(events[events.length - 1].timestamp).getTime() - new Date(events[0].timestamp).getTime()) / (1000 * 60 * 60 * 24) : 1; const averageEventsPerDay = events.length / Math.max(timeSpan, 1); // Find peak event type const peakEventType = Object.entries(eventsByType) .reduce((max, [type, count]) => count > max.count ? { type: type, count } : max, { type: WebhookEventType.TRANSACTION_CONFIRMED, count: 0 }) .type; return { mostActiveHour, averageEventsPerDay, peakEventType }; } /** * Calculate performance metrics */ calculatePerformanceMetrics() { const total = this.processingStats.totalProcessed; return { averageProcessingTime: this.processingStats.averageProcessingTime, successRate: total > 0 ? (this.processingStats.successfullyProcessed / total) * 100 : 0, errorRate: total > 0 ? (this.processingStats.errors / total) * 100 : 0 }; } /** * Create empty insights object */ createEmptyInsights(timeRange) { const now = new Date().toISOString(); return { totalEvents: 0, eventsByType: {}, timeRange: timeRange || { start: now, end: now }, patterns: { mostActiveHour: 0, averageEventsPerDay: 0, peakEventType: WebhookEventType.TRANSACTION_CONFIRMED }, performance: { averageProcessingTime: 0, successRate: 0, errorRate: 0 } }; } /** * Create Winston logger instance */ createLogger() { return winston.createLogger({ level: process.env['LOG_LEVEL'] || 'info', format: winston.format.combine(winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json()), defaultMeta: { service: 'webhook-event-processor' }, transports: [ new winston.transports.Console({ format: winston.format.combine(winston.format.colorize(), winston.format.simple()) }) ] }); } } //# sourceMappingURL=WebhookEventProcessor.js.map