159 lines
4.7 KiB
JavaScript
159 lines
4.7 KiB
JavaScript
|
|
import { assertEthereumObserver } from '../protocols/ethereum/EthereumObserver.js';
|
|
import * as AddressModel from '../models/AddressModel.js';
|
|
import * as CheckpointModel from '../models/AddressCheckpointModel.js';
|
|
|
|
/**
|
|
* @typedef {import('../domain/NormalizedTx.js').NormalizedTx} NormalizedTx
|
|
* @typedef {import('../domain/ObservedTx.js').ObservedTx} ObservedTx
|
|
* @typedef {import('../protocols/ethereum/EthereumObserver.js').EthereumObserver} EthereumObserver
|
|
*/
|
|
|
|
// Safety limit: maximum blocks to process per address per run
|
|
// Prevents overwhelming RPC endpoints and ensures bounded execution time
|
|
const MAX_BLOCKS_PER_RUN = 100;
|
|
|
|
|
|
export class ObserverService {
|
|
/**
|
|
* @param {EthereumObserver} ethObserver - Implementation of EthereumObserver interface
|
|
*/
|
|
constructor(ethObserver) {
|
|
assertEthereumObserver(ethObserver, 'ethObserver');
|
|
this.eth = ethObserver;
|
|
}
|
|
|
|
/**
|
|
* Run one observation cycle across all tracked addresses
|
|
*
|
|
*
|
|
* @returns {Promise<ObservedTx[]>} All observations from this cycle
|
|
*/
|
|
async runOnce() {
|
|
const addresses = await AddressModel.list();
|
|
|
|
if (addresses.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const latestBlock = await this.eth.getLatestBlockNumber();
|
|
const observations = [];
|
|
|
|
for (const address of addresses) {
|
|
try {
|
|
const addressObservations = await this._observeAddress(address, latestBlock);
|
|
observations.push(...addressObservations);
|
|
} catch (error) {
|
|
console.error(`Error observing address ${address.address}:`, error);
|
|
|
|
}
|
|
}
|
|
|
|
return observations;
|
|
}
|
|
|
|
/**
|
|
* Observe a single address for activity
|
|
* @private
|
|
* @param {Object} address - Address record from database
|
|
* @param {number} latestBlock - Current latest block number
|
|
* @returns {Promise<ObservedTx[]>} Observations for this address
|
|
*/
|
|
async _observeAddress(address, latestBlock) {
|
|
const lastChecked = await CheckpointModel.getLastCheckedBlock(address.id);
|
|
|
|
const startBlock = this._getStartBlock(lastChecked, latestBlock);
|
|
const endBlock = this._getEndBlock(startBlock, latestBlock);
|
|
|
|
// No new blocks to check
|
|
if (startBlock > endBlock) {
|
|
return [];
|
|
}
|
|
|
|
const observations = [];
|
|
|
|
for (let blockNumber = startBlock; blockNumber <= endBlock; blockNumber++) {
|
|
const blockTxs = await this.eth.getBlockTransactions(blockNumber);
|
|
const relevantTxs = this._filterRelevantTransactions(blockTxs, address.address);
|
|
|
|
for (const tx of relevantTxs) {
|
|
observations.push(this._createObservedTx(tx, address));
|
|
}
|
|
}
|
|
|
|
await CheckpointModel.updateLastCheckedBlock(address.id, endBlock);
|
|
|
|
return observations;
|
|
}
|
|
|
|
/**
|
|
* Determine starting block for observation
|
|
* @private
|
|
* @param {number|null} lastChecked - Last checked block, or null if never checked
|
|
* @param {number} latestBlock - Current latest block
|
|
* @returns {number} Block number to start from
|
|
*/
|
|
_getStartBlock(lastChecked, latestBlock) {
|
|
if (lastChecked === null) {
|
|
return latestBlock;
|
|
}
|
|
return lastChecked + 1;
|
|
}
|
|
|
|
/**
|
|
* Determine ending block for observation (respects MAX_BLOCKS_PER_RUN)
|
|
* @private
|
|
* @param {number} startBlock
|
|
* @param {number} latestBlock
|
|
* @returns {number}
|
|
*/
|
|
_getEndBlock(startBlock, latestBlock) {
|
|
return Math.min(startBlock + MAX_BLOCKS_PER_RUN - 1, latestBlock);
|
|
}
|
|
|
|
/**
|
|
* Filter transactions to only those involving the tracked address
|
|
* @private
|
|
* @param {NormalizedTx[]} transactions
|
|
* @param {string} trackedAddress
|
|
* @returns {NormalizedTx[]}
|
|
*/
|
|
_filterRelevantTransactions(transactions, trackedAddress) {
|
|
const addressLower = trackedAddress.toLowerCase();
|
|
|
|
return transactions.filter((tx) => {
|
|
const fromMatch = tx.from?.toLowerCase() === addressLower;
|
|
const toMatch = tx.to?.toLowerCase() === addressLower;
|
|
return fromMatch || toMatch;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create an ObservedTx with direction metadata
|
|
* @private
|
|
* @param {NormalizedTx} tx - Normalized transaction
|
|
* @param {Object} address - Address record from database
|
|
* @returns {ObservedTx} Transaction with observation metadata
|
|
*/
|
|
_createObservedTx(tx, address) {
|
|
const addressLower = address.address.toLowerCase();
|
|
const direction = tx.to?.toLowerCase() === addressLower ? 'incoming' : 'outgoing';
|
|
|
|
return {
|
|
...tx,
|
|
addressId: address.id,
|
|
direction,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Factory to create an ObserverService instance
|
|
* @param {EthereumObserver} ethObserver - Implementation of EthereumObserver interface
|
|
* @returns {ObserverService}
|
|
*/
|
|
export function createObserverService(ethObserver) {
|
|
return new ObserverService(ethObserver);
|
|
}
|
|
|