@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
JavaScript
/**
* 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