@iqai/mcp-near-agent
Version:
Mcp server for Near
327 lines โข 13.4 kB
JavaScript
import { EventEmitter } from "node:events";
import * as cron from "node-cron";
import { env } from "../env.js";
import { AuthManager } from "./auth-manager.js";
export class EventListener extends EventEmitter {
static BATCH_SIZE = 5;
static POLL_DELAY = 500; // ms between batches
authManager;
processingStates = new Map();
constructor(config = {}) {
super();
this.authManager = AuthManager.getInstance();
}
/**
* Initialize the NEAR connection using AuthManager
*/
async initialize() {
if (this.authManager.isReady()) {
console.log("โ
EventListener using existing NEAR connection");
return;
}
console.log("๐ Initializing NEAR connection for EventListener...");
try {
await this.authManager.initialize();
console.log("โ
EventListener initialized successfully");
}
catch (error) {
console.error("โ Failed to initialize EventListener:", error);
throw new Error(`EventListener initialization failed: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
/**
* Get the NEAR account from AuthManager
*/
getAccount() {
const account = this.authManager.getAccount();
if (!account) {
throw new Error("EventListener not initialized. Call initialize() first.");
}
return account;
}
/**
* Start listening for events for a specific subscription
*/
startListening(subscription) {
if (!this.authManager.isReady()) {
throw new Error("EventListener not initialized. Call initialize() first.");
}
const subscriptionId = subscription.id;
// Initialize processing state for this subscription
if (!this.processingStates.has(subscriptionId)) {
this.processingStates.set(subscriptionId, {
lastBlockHeight: 0,
isProcessing: false,
processedTransactionIds: new Set(),
});
this.emit("state:initialized", subscriptionId);
}
console.log(`๐ฏ Starting to listen for '${subscription.eventName}' events on contract '${subscription.contractId}'`);
const cronJob = cron.schedule(subscription.cronExpression, async () => {
const state = this.processingStates.get(subscriptionId);
if (!state || state.isProcessing) {
return; // Skip if already processing
}
try {
this.emit("polling:started", subscriptionId);
const result = await this.pollEventsForSubscription(subscription, state);
this.emit("polling:completed", {
subscriptionId,
blocksProcessed: result.blocksProcessed,
eventsFound: result.eventsFound,
});
}
catch (error) {
console.error(`โ Error polling events for subscription ${subscriptionId}:`, error);
this.emit("event:error", { subscription, error });
}
});
cronJob.start();
return cronJob;
}
/**
* Stop listening for a specific subscription
*/
stopListening(subscriptionId) {
this.processingStates.delete(subscriptionId);
console.log(`๐ Stopped listening for subscription: ${subscriptionId}`);
}
/**
* Poll for events for a specific subscription
*/
async pollEventsForSubscription(subscription, state) {
state.isProcessing = true;
let blocksProcessed = 0;
let eventsFound = 0;
try {
const currentBlock = await this.getCurrentBlock(state);
if (!currentBlock) {
return { blocksProcessed, eventsFound };
}
const currentHeight = currentBlock.header.height;
const startHeight = state.lastBlockHeight + 1;
if (startHeight > currentHeight) {
return { blocksProcessed, eventsFound }; // No new blocks to process
}
console.log(`๐ Processing blocks ${startHeight} to ${currentHeight} for ${subscription.eventName}`);
// Process blocks in batches
for (let blockHeight = startHeight; blockHeight <= currentHeight; blockHeight += EventListener.BATCH_SIZE) {
const endBatch = Math.min(blockHeight + EventListener.BATCH_SIZE - 1, currentHeight);
const promises = [];
for (let height = blockHeight; height <= endBatch; height++) {
promises.push(this.processBlock(height, subscription, state));
}
const results = await Promise.all(promises);
const batchEventsFound = results.reduce((sum, result) => sum + result.eventsFound, 0);
blocksProcessed += endBatch - blockHeight + 1;
eventsFound += batchEventsFound;
state.lastBlockHeight = endBatch;
// Add delay between batches to avoid overwhelming the RPC
if (endBatch < currentHeight) {
await new Promise((resolve) => setTimeout(resolve, EventListener.POLL_DELAY));
}
}
return { blocksProcessed, eventsFound };
}
finally {
state.isProcessing = false;
}
}
/**
* Process a single block for events
*/
async processBlock(blockHeight, subscription, state) {
let eventsFound = 0;
try {
const account = this.getAccount();
const block = await account.provider.viewBlock({
blockId: blockHeight,
});
const relevantReceipts = await this.getRelevantReceipts(block, subscription.contractId);
if (relevantReceipts.length > 0) {
eventsFound = await this.processReceipts(relevantReceipts, subscription, state);
}
this.emit("block:processed", {
blockHeight,
subscriptionId: subscription.id,
eventsFound,
});
return { eventsFound };
}
catch (error) {
console.error(`โ Error processing block ${blockHeight}:`, error);
this.emit("block:error", {
blockHeight,
subscriptionId: subscription.id,
error,
});
return { eventsFound: 0 };
}
}
/**
* Get the current block and initialize lastBlockHeight if needed
*/
async getCurrentBlock(state) {
const account = this.getAccount();
const currentBlock = await account.provider.viewBlock({
finality: "final",
});
// Initialize lastBlockHeight to previous block on first run
if (state.lastBlockHeight === 0) {
state.lastBlockHeight = currentBlock.header.height - 1;
console.log(`๐ Initialized block height to ${state.lastBlockHeight}`);
}
return currentBlock;
}
/**
* Get relevant receipts for a contract from a block
*/
async getRelevantReceipts(block, contractId) {
const account = this.getAccount();
const relevantReceipts = [];
try {
const blockDetails = await account.provider.viewBlock({
blockId: block.header.height,
});
for (const chunk of blockDetails.chunks) {
try {
const chunkDetails = await account.provider.viewChunk(chunk.chunk_hash);
for (const receipt of chunkDetails.receipts) {
if (receipt.receiver_id === contractId) {
relevantReceipts.push({
data: receipt,
receiver_id: receipt.receiver_id,
receipt_id: receipt.receipt_id,
predecessor_id: receipt.predecessor_id,
block_height: block.header.height,
});
}
}
}
catch (chunkError) {
// Skip this chunk if there's an error
console.warn(`โ ๏ธ Error processing chunk ${chunk.chunk_hash}:`, chunkError);
}
}
}
catch (error) {
console.error(`โ Error getting receipts for block ${block.header.height}:`, error);
}
return relevantReceipts;
}
/**
* Process receipts and extract events
*/
async processReceipts(receipts, subscription, state) {
let eventsFound = 0;
for (const receipt of receipts) {
try {
const events = await this.extractEventsFromReceipt(receipt.data, subscription, state);
for (const event of events) {
console.log(`๐ Found event '${event.eventType}' for subscription ${subscription.id}`);
this.emit("event:found", { event, subscription });
eventsFound++;
}
}
catch (error) {
console.error(`โ Error processing receipt ${receipt.receipt_id}:`, error);
}
}
return eventsFound;
}
/**
* Extract events from a receipt using NearBlocks API
*/
async extractEventsFromReceipt(receipt, subscription, state) {
const events = [];
try {
const apiUrl = env.NEAR_NETWORK_ID === "mainnet"
? "https://api.nearblocks.io/v1/search"
: "https://api-testnet.nearblocks.io/v1/search";
const response = await fetch(`${apiUrl}?keyword=${receipt.receipt_id}`);
if (!response.ok) {
throw new Error(`NearBlocks API error: ${response.status}`);
}
const data = (await response.json());
if (data.receipts && data.receipts.length > 0) {
const txHash = data.receipts[0].originated_from_transaction_hash;
if (txHash && !state.processedTransactionIds.has(txHash)) {
state.processedTransactionIds.add(txHash);
// Clean up old transaction IDs to prevent memory leaks
if (state.processedTransactionIds.size > 10000) {
const idsArray = Array.from(state.processedTransactionIds);
const keepIds = idsArray.slice(-5000); // Keep last 5000
state.processedTransactionIds = new Set(keepIds);
}
const account = this.getAccount();
const txStatus = await account.provider.viewTransactionStatus(txHash, subscription.contractId, "INCLUDED");
for (const { outcome } of txStatus.receipts_outcome) {
for (const log of outcome.logs) {
const event = this.parseEventLog(log, subscription.eventName, receipt.predecessor_id);
if (event) {
events.push(event);
}
}
}
}
}
}
catch (error) {
console.error("โ Error extracting events from receipt:", error);
}
return events;
}
/**
* Parse an event log and return an AgentEvent if it matches
*/
parseEventLog(log, eventName, signerId) {
try {
if (log.startsWith("EVENT_JSON:")) {
const jsonStr = log.slice("EVENT_JSON:".length);
const eventData = JSON.parse(jsonStr);
if (eventData.event === eventName &&
Array.isArray(eventData.data) &&
eventData.data.length > 0) {
return {
eventType: eventData.event,
requestId: eventData.data[0].request_id,
payload: eventData.data[0],
sender: signerId,
timestamp: Date.now(),
};
}
}
}
catch (error) {
console.error("โ Error parsing event log:", error);
}
return null;
}
/**
* Get processing statistics
*/
getStats() {
const states = Array.from(this.processingStates.entries());
return {
isInitialized: this.authManager.isReady(),
activeSubscriptions: states.length,
authManagerStatus: this.authManager.getStatus(),
processingStates: states.map(([id, state]) => ({
subscriptionId: id,
lastBlockHeight: state.lastBlockHeight,
isProcessing: state.isProcessing,
processedTransactionCount: state.processedTransactionIds.size,
})),
};
}
/**
* Cleanup resources
*/
cleanup() {
console.log("๐งน Cleaning up EventListener...");
this.processingStates.clear();
this.removeAllListeners();
console.log("โ
EventListener cleaned up");
}
}
//# sourceMappingURL=event-listener.js.map