fixing empty transaction
This commit is contained in:
@@ -158,6 +158,15 @@ export class Application {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightweight pre-fetch filter using websocket/log data
|
||||||
|
if (wsPayload.logs && Array.isArray(wsPayload.logs)) {
|
||||||
|
const { isLogIrrelevant } = await import('../solana/parser.js');
|
||||||
|
if (isLogIrrelevant(wsPayload.logs)) {
|
||||||
|
logger.info(`[RealMode] Pre-filtered obviously irrelevant transaction signature ${observation.signature} based on log analysis.`, { correlationId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[RealMode] Activity detected for wallet ${observation.walletAddress}. Signature: ${observation.signature}, Slot: ${observation.slot}`,
|
`[RealMode] Activity detected for wallet ${observation.walletAddress}. Signature: ${observation.signature}, Slot: ${observation.slot}`,
|
||||||
{ correlationId },
|
{ correlationId },
|
||||||
@@ -188,7 +197,9 @@ export class Application {
|
|||||||
|
|
||||||
// Push item onto the bounded queue for non-blocking execution
|
// Push item onto the bounded queue for non-blocking execution
|
||||||
if (!this.transactionQueue) {
|
if (!this.transactionQueue) {
|
||||||
logger.error('[RealMode] Transaction queue not initialized!');
|
if (!this.isShuttingDown) {
|
||||||
|
logger.error('[RealMode] Transaction queue not initialized!');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,20 +396,23 @@ export class Application {
|
|||||||
|
|
||||||
logger.warn(`Shutdown signal received (${signal}). Commencing graceful shutdown...`);
|
logger.warn(`Shutdown signal received (${signal}). Commencing graceful shutdown...`);
|
||||||
|
|
||||||
|
// 1. Stop accepting new watcher events / unsubscribe first
|
||||||
|
if (this.watcher) {
|
||||||
|
await this.watcher.stopWatching();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Clear intervals and allow pending callbacks to settle cleanly
|
||||||
if (this.simulationIntervalId) {
|
if (this.simulationIntervalId) {
|
||||||
clearInterval(this.simulationIntervalId);
|
clearInterval(this.simulationIntervalId);
|
||||||
this.simulationIntervalId = null;
|
this.simulationIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Stop and clear transaction queues
|
||||||
if (this.transactionQueue) {
|
if (this.transactionQueue) {
|
||||||
this.transactionQueue.stop();
|
this.transactionQueue.stop();
|
||||||
this.transactionQueue = null;
|
this.transactionQueue = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.watcher) {
|
|
||||||
await this.watcher.stopWatching();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const journalRepo = new PgEventJournalRepository();
|
const journalRepo = new PgEventJournalRepository();
|
||||||
@@ -413,6 +427,7 @@ export class Application {
|
|||||||
logger.error('Could not log application_stopping event during shutdown', err);
|
logger.error('Could not log application_stopping event during shutdown', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Close InfluxDB / PostgreSQL metrics & data connections
|
||||||
if (this.metricsProvider && 'close' in this.metricsProvider) {
|
if (this.metricsProvider && 'close' in this.metricsProvider) {
|
||||||
await this.metricsProvider.close();
|
await this.metricsProvider.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,4 +143,7 @@ export interface IMetricsProvider {
|
|||||||
recordRpcRequestRate(requestsPerSecond: number): void;
|
recordRpcRequestRate(requestsPerSecond: number): void;
|
||||||
recordQueueProcessed(wallet: string): void;
|
recordQueueProcessed(wallet: string): void;
|
||||||
recordRpcFetchError(wallet: string): void;
|
recordRpcFetchError(wallet: string): void;
|
||||||
|
recordWalletState(wallet: string, isHot: boolean): void;
|
||||||
|
recordWalletIncomingRate(wallet: string, rate: number): void;
|
||||||
|
recordWalletProcessedRate(wallet: string, rate: number): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,21 @@ export class InfluxMetricsProvider implements IMetricsProvider {
|
|||||||
this.writePoint(p);
|
this.writePoint(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordWalletState(wallet: string, isHot: boolean): void {
|
||||||
|
const p = new Point('wallet_state').tag('wallet', wallet).intField('is_hot', isHot ? 1 : 0);
|
||||||
|
this.writePoint(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordWalletIncomingRate(wallet: string, rate: number): void {
|
||||||
|
const p = new Point('wallet_incoming_rate').tag('wallet', wallet).floatField('rate', rate);
|
||||||
|
this.writePoint(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordWalletProcessedRate(wallet: string, rate: number): void {
|
||||||
|
const p = new Point('wallet_processed_rate').tag('wallet', wallet).floatField('rate', rate);
|
||||||
|
this.writePoint(p);
|
||||||
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
if (this.writeApi) {
|
if (this.writeApi) {
|
||||||
logger.info('Closing InfluxDB metrics writer...');
|
logger.info('Closing InfluxDB metrics writer...');
|
||||||
@@ -180,4 +195,7 @@ export class MockMetricsProvider implements IMetricsProvider {
|
|||||||
recordRpcRequestRate(_requestsPerSecond: number): void {}
|
recordRpcRequestRate(_requestsPerSecond: number): void {}
|
||||||
recordQueueProcessed(_wallet: string): void {}
|
recordQueueProcessed(_wallet: string): void {}
|
||||||
recordRpcFetchError(_wallet: string): void {}
|
recordRpcFetchError(_wallet: string): void {}
|
||||||
|
recordWalletState(_wallet: string, _isHot: boolean): void {}
|
||||||
|
recordWalletIncomingRate(_wallet: string, _rate: number): void {}
|
||||||
|
recordWalletProcessedRate(_wallet: string, _rate: number): void {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,20 @@ export async function fetchTransactionWithRetry(
|
|||||||
initialDelayMs = 500,
|
initialDelayMs = 500,
|
||||||
maxDelayMs = 8000,
|
maxDelayMs = 8000,
|
||||||
metricsProvider?: IMetricsProvider,
|
metricsProvider?: IMetricsProvider,
|
||||||
|
transactionDetails: 'accounts' | 'full' = 'full',
|
||||||
|
onRateLimit?: () => void,
|
||||||
): Promise<VersionedTransactionResponse> {
|
): Promise<VersionedTransactionResponse> {
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
let delay = initialDelayMs;
|
let delay = initialDelayMs;
|
||||||
|
|
||||||
while (attempt < maxRetries) {
|
while (attempt < maxRetries) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`[TransactionFetcher] Fetching transaction ${signature} (attempt ${attempt + 1}/${maxRetries})...`);
|
logger.debug(`[TransactionFetcher] Fetching transaction ${signature} (attempt ${attempt + 1}/${maxRetries}, details: ${transactionDetails})...`);
|
||||||
const tx = await connection.getTransaction(signature, {
|
const tx = await connection.getTransaction(signature, {
|
||||||
commitment: 'confirmed',
|
commitment: 'confirmed',
|
||||||
maxSupportedTransactionVersion: 0,
|
maxSupportedTransactionVersion: 0,
|
||||||
});
|
transactionDetails,
|
||||||
|
} as any);
|
||||||
|
|
||||||
if (tx) {
|
if (tx) {
|
||||||
return tx;
|
return tx;
|
||||||
@@ -33,6 +36,9 @@ export async function fetchTransactionWithRetry(
|
|||||||
if (metricsProvider) {
|
if (metricsProvider) {
|
||||||
metricsProvider.recordQueue429s(signature);
|
metricsProvider.recordQueue429s(signature);
|
||||||
}
|
}
|
||||||
|
if (onRateLimit) {
|
||||||
|
onRateLimit();
|
||||||
|
}
|
||||||
// Use exponential backoff with jitter for HTTP 429
|
// Use exponential backoff with jitter for HTTP 429
|
||||||
const jitter = Math.random() * 200;
|
const jitter = Math.random() * 200;
|
||||||
const rateLimitDelay = Math.min(delay * 3 + jitter, maxDelayMs * 2);
|
const rateLimitDelay = Math.min(delay * 3 + jitter, maxDelayMs * 2);
|
||||||
@@ -103,13 +109,65 @@ export class BoundedTransactionQueue {
|
|||||||
private rateSecondTimer: NodeJS.Timeout | null = null;
|
private rateSecondTimer: NodeJS.Timeout | null = null;
|
||||||
private rateLimitTimeout: NodeJS.Timeout | null = null;
|
private rateLimitTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Wallet tracking stats for rate and state tracking
|
||||||
|
private walletIncomingCountLastSecond = new Map<string, number>();
|
||||||
|
private walletProcessedCountLastSecond = new Map<string, number>();
|
||||||
|
private walletIncomingRate = new Map<string, number>();
|
||||||
|
private walletProcessedRate = new Map<string, number>();
|
||||||
|
private walletStates = new Map<string, 'NORMAL' | 'HOT'>();
|
||||||
|
|
||||||
|
// Adaptive rate-limiting variables
|
||||||
|
private adaptiveDelayMs: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private connection: Connection,
|
private connection: Connection,
|
||||||
private maxConcurrency: number,
|
private maxConcurrency: number,
|
||||||
private minDelayMs: number,
|
private minDelayMs: number,
|
||||||
private maxQueueSize: number, // Bounded limit PER wallet queue
|
private maxQueueSize: number, // Bounded limit PER wallet queue
|
||||||
private metricsProvider?: IMetricsProvider,
|
private metricsProvider?: IMetricsProvider,
|
||||||
) {}
|
) {
|
||||||
|
this.adaptiveDelayMs = this.minDelayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onRateLimitHit(): void {
|
||||||
|
const oldDelay = this.adaptiveDelayMs;
|
||||||
|
this.adaptiveDelayMs = Math.min(this.adaptiveDelayMs * 1.5, 5000);
|
||||||
|
logger.warn(`[BoundedTransactionQueue] Rate limit (429) detected. Increasing adaptive minDelayMs from ${oldDelay}ms to ${this.adaptiveDelayMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onFetchSuccess(): void {
|
||||||
|
if (this.adaptiveDelayMs > this.minDelayMs) {
|
||||||
|
const oldDelay = this.adaptiveDelayMs;
|
||||||
|
this.adaptiveDelayMs = Math.max(this.minDelayMs, this.adaptiveDelayMs - 50);
|
||||||
|
logger.debug(`[BoundedTransactionQueue] Successful fetch. Decaying adaptive minDelayMs from ${oldDelay}ms to ${this.adaptiveDelayMs}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateWalletState(wallet: string): void {
|
||||||
|
const qSize = this.walletQueues.get(wallet)?.length || 0;
|
||||||
|
const incRate = this.walletIncomingRate.get(wallet) || 0;
|
||||||
|
const isNearCapacity = qSize >= this.maxQueueSize * 0.8;
|
||||||
|
const isHighRate = incRate > 10;
|
||||||
|
|
||||||
|
const oldState = this.walletStates.get(wallet) || 'NORMAL';
|
||||||
|
let newState: 'NORMAL' | 'HOT' = 'NORMAL';
|
||||||
|
if (isNearCapacity || isHighRate) {
|
||||||
|
newState = 'HOT';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldState !== newState) {
|
||||||
|
this.walletStates.set(wallet, newState);
|
||||||
|
logger.warn(`[BoundedTransactionQueue] Wallet ${wallet} transitioned from ${oldState} to ${newState}. Queue depth: ${qSize}, Incoming rate: ${incRate}/s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.metricsProvider) {
|
||||||
|
this.metricsProvider.recordWalletState(wallet, newState === 'HOT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getWalletState(wallet: string): 'NORMAL' | 'HOT' {
|
||||||
|
return this.walletStates.get(wallet) || 'NORMAL';
|
||||||
|
}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
if (this.running) return;
|
if (this.running) return;
|
||||||
@@ -121,6 +179,24 @@ export class BoundedTransactionQueue {
|
|||||||
this.metricsProvider.recordRpcRequestRate(this.requestCountLastSecond);
|
this.metricsProvider.recordRpcRequestRate(this.requestCountLastSecond);
|
||||||
}
|
}
|
||||||
this.requestCountLastSecond = 0;
|
this.requestCountLastSecond = 0;
|
||||||
|
|
||||||
|
for (const wallet of this.walletQueues.keys()) {
|
||||||
|
const incomingCount = this.walletIncomingCountLastSecond.get(wallet) || 0;
|
||||||
|
const processedCount = this.walletProcessedCountLastSecond.get(wallet) || 0;
|
||||||
|
|
||||||
|
this.walletIncomingRate.set(wallet, incomingCount);
|
||||||
|
this.walletProcessedRate.set(wallet, processedCount);
|
||||||
|
|
||||||
|
this.walletIncomingCountLastSecond.set(wallet, 0);
|
||||||
|
this.walletProcessedCountLastSecond.set(wallet, 0);
|
||||||
|
|
||||||
|
if (this.metricsProvider) {
|
||||||
|
this.metricsProvider.recordWalletIncomingRate(wallet, incomingCount);
|
||||||
|
this.metricsProvider.recordWalletProcessedRate(wallet, processedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateWalletState(wallet);
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +237,9 @@ export class BoundedTransactionQueue {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentIncoming = this.walletIncomingCountLastSecond.get(item.walletAddress) || 0;
|
||||||
|
this.walletIncomingCountLastSecond.set(item.walletAddress, currentIncoming + 1);
|
||||||
|
|
||||||
if (this.metricsProvider) {
|
if (this.metricsProvider) {
|
||||||
this.metricsProvider.recordWalletEventReceived(item.walletAddress);
|
this.metricsProvider.recordWalletEventReceived(item.walletAddress);
|
||||||
}
|
}
|
||||||
@@ -206,6 +285,8 @@ export class BoundedTransactionQueue {
|
|||||||
this.walletOrder.push(item.walletAddress);
|
this.walletOrder.push(item.walletAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateWalletState(item.walletAddress);
|
||||||
|
|
||||||
logger.debug(`[BoundedTransactionQueue] Queued signature ${item.signature} for wallet ${item.walletAddress}. Depth: ${wQueue.length}`);
|
logger.debug(`[BoundedTransactionQueue] Queued signature ${item.signature} for wallet ${item.walletAddress}. Depth: ${wQueue.length}`);
|
||||||
|
|
||||||
// Trigger process next non-blockingly
|
// Trigger process next non-blockingly
|
||||||
@@ -228,10 +309,10 @@ export class BoundedTransactionQueue {
|
|||||||
// Enforcement of rate limiting delay
|
// Enforcement of rate limiting delay
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeSinceLast = now - this.lastRequestTime;
|
const timeSinceLast = now - this.lastRequestTime;
|
||||||
if (timeSinceLast < this.minDelayMs) {
|
if (timeSinceLast < this.adaptiveDelayMs) {
|
||||||
const waitTime = this.minDelayMs - timeSinceLast;
|
const waitTime = this.adaptiveDelayMs - timeSinceLast;
|
||||||
if (!this.rateLimitTimeout) {
|
if (!this.rateLimitTimeout) {
|
||||||
logger.debug(`[BoundedTransactionQueue] Global rate limit: waiting ${waitTime}ms`);
|
logger.debug(`[BoundedTransactionQueue] Global rate limit: waiting ${waitTime}ms (adaptive delay: ${this.adaptiveDelayMs}ms)`);
|
||||||
this.rateLimitTimeout = setTimeout(() => {
|
this.rateLimitTimeout = setTimeout(() => {
|
||||||
this.rateLimitTimeout = null;
|
this.rateLimitTimeout = null;
|
||||||
this.processNext();
|
this.processNext();
|
||||||
@@ -255,6 +336,8 @@ export class BoundedTransactionQueue {
|
|||||||
this.walletOrder.push(walletAddr);
|
this.walletOrder.push(walletAddr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateWalletState(walletAddr);
|
||||||
|
|
||||||
if (this.metricsProvider) {
|
if (this.metricsProvider) {
|
||||||
this.metricsProvider.recordWalletQueueDepth(walletAddr, wQueue.length);
|
this.metricsProvider.recordWalletQueueDepth(walletAddr, wQueue.length);
|
||||||
this.metricsProvider.recordQueueDepth(this.getDepth());
|
this.metricsProvider.recordQueueDepth(this.getDepth());
|
||||||
@@ -276,15 +359,51 @@ export class BoundedTransactionQueue {
|
|||||||
private async executeFetch(item: QueueItem): Promise<void> {
|
private async executeFetch(item: QueueItem): Promise<void> {
|
||||||
const fetchStartTime = Date.now();
|
const fetchStartTime = Date.now();
|
||||||
try {
|
try {
|
||||||
const tx = await fetchTransactionWithRetry(
|
// 1. Cheap pre-fetch to determine relevance
|
||||||
|
logger.debug(`[BoundedTransactionQueue] Pre-filtering transaction ${item.signature} with cheap accounts-only RPC call...`);
|
||||||
|
let tx = await fetchTransactionWithRetry(
|
||||||
this.connection,
|
this.connection,
|
||||||
item.signature,
|
item.signature,
|
||||||
5,
|
5,
|
||||||
500,
|
500,
|
||||||
4000,
|
4000,
|
||||||
this.metricsProvider,
|
this.metricsProvider,
|
||||||
|
'accounts',
|
||||||
|
() => this.onRateLimitHit(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 2. Classify transaction relevance (with robustness for test mocks lacking full transaction structures)
|
||||||
|
let relevant = true;
|
||||||
|
let shouldFetchFull = true;
|
||||||
|
|
||||||
|
if (tx && tx.transaction && tx.transaction.message) {
|
||||||
|
const { isTransactionRelevant } = await import('./parser.js');
|
||||||
|
relevant = isTransactionRelevant(tx, item.walletAddress);
|
||||||
|
|
||||||
|
const { loadConfig } = await import('../config/index.js');
|
||||||
|
const config = loadConfig();
|
||||||
|
shouldFetchFull = relevant || config.STORE_IRRELEVANT_RAW_TRANSACTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If relevant (or storing irrelevant is enabled), fetch full transaction details
|
||||||
|
if (shouldFetchFull) {
|
||||||
|
logger.debug(`[BoundedTransactionQueue] Transaction ${item.signature} is RELEVANT or requires full details. Fetching full transaction details...`);
|
||||||
|
tx = await fetchTransactionWithRetry(
|
||||||
|
this.connection,
|
||||||
|
item.signature,
|
||||||
|
5,
|
||||||
|
500,
|
||||||
|
4000,
|
||||||
|
this.metricsProvider,
|
||||||
|
'full',
|
||||||
|
() => this.onRateLimitHit(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(`[BoundedTransactionQueue] Pre-filtered: Transaction ${item.signature} is IRRELEVANT. Skipping full fetch to prevent RPC 429 throttling.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onFetchSuccess();
|
||||||
|
|
||||||
const fetchEndTime = Date.now();
|
const fetchEndTime = Date.now();
|
||||||
const fetchDuration = fetchEndTime - fetchStartTime;
|
const fetchDuration = fetchEndTime - fetchStartTime;
|
||||||
|
|
||||||
@@ -293,6 +412,9 @@ export class BoundedTransactionQueue {
|
|||||||
this.metricsProvider.recordQueueProcessed(item.walletAddress);
|
this.metricsProvider.recordQueueProcessed(item.walletAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const procCount = this.walletProcessedCountLastSecond.get(item.walletAddress) || 0;
|
||||||
|
this.walletProcessedCountLastSecond.set(item.walletAddress, procCount + 1);
|
||||||
|
|
||||||
await item.onSuccess(tx);
|
await item.onSuccess(tx);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(`[BoundedTransactionQueue] Processing failed for signature ${item.signature}`, err);
|
logger.error(`[BoundedTransactionQueue] Processing failed for signature ${item.signature}`, err);
|
||||||
@@ -302,6 +424,7 @@ export class BoundedTransactionQueue {
|
|||||||
await item.onFailure(err);
|
await item.onFailure(err);
|
||||||
} finally {
|
} finally {
|
||||||
this.activeCount--;
|
this.activeCount--;
|
||||||
|
this.updateWalletState(item.walletAddress);
|
||||||
logger.debug(`[BoundedTransactionQueue] Finished item ${item.signature}. Active: ${this.activeCount}`);
|
logger.debug(`[BoundedTransactionQueue] Finished item ${item.signature}. Active: ${this.activeCount}`);
|
||||||
this.processNext();
|
this.processNext();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,3 +138,18 @@ export function isTransactionRelevant(
|
|||||||
|
|
||||||
return meaningfulSol || meaningfulToken;
|
return meaningfulSol || meaningfulToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isLogIrrelevant(logs: string[]): boolean {
|
||||||
|
if (!logs || logs.length === 0) return false;
|
||||||
|
|
||||||
|
// High confidence irrelevant signature patterns:
|
||||||
|
// Validator vote transactions (contain Vote111111111111111111111111111111111111111)
|
||||||
|
const hasVote = logs.some((line) =>
|
||||||
|
line.includes('Vote111111111111111111111111111111111111111'),
|
||||||
|
);
|
||||||
|
if (hasVote) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { logger } from '../logging/index.js';
|
|||||||
|
|
||||||
export class RealWalletWatcher implements IWalletWatcher {
|
export class RealWalletWatcher implements IWalletWatcher {
|
||||||
private callback: ((obs: WalletTransactionObservation) => Promise<void>) | null = null;
|
private callback: ((obs: WalletTransactionObservation) => Promise<void>) | null = null;
|
||||||
private connection: Connection | null = null;
|
public connection: Connection | null = null;
|
||||||
private subscriptionIds: number[] = [];
|
|
||||||
private active = false;
|
private active = false;
|
||||||
|
private lastKnownSignatures = new Map<string, string>();
|
||||||
|
private pollTimeouts: NodeJS.Timeout[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private watchedWallets: string[],
|
private watchedWallets: string[],
|
||||||
@@ -29,35 +30,95 @@ export class RealWalletWatcher implements IWalletWatcher {
|
|||||||
for (const wallet of this.watchedWallets) {
|
for (const wallet of this.watchedWallets) {
|
||||||
try {
|
try {
|
||||||
const pubkey = new PublicKey(wallet);
|
const pubkey = new PublicKey(wallet);
|
||||||
const subId = this.connection.onLogs(
|
|
||||||
|
// Get the latest signature on startup so we only watch for NEW transactions
|
||||||
|
const initialSigs = await this.connection.getSignaturesForAddress(
|
||||||
pubkey,
|
pubkey,
|
||||||
async (logs, context) => {
|
{ limit: 1 },
|
||||||
if (!this.callback) return;
|
|
||||||
if (!logs.signature || !this.active) return;
|
|
||||||
|
|
||||||
const correlationId = randomUUID();
|
|
||||||
const observation: WalletTransactionObservation = {
|
|
||||||
signature: logs.signature,
|
|
||||||
slot: context.slot,
|
|
||||||
walletAddress: wallet,
|
|
||||||
timestamp: new Date(),
|
|
||||||
correlationId,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info(`[RealWalletWatcher] Activity detected for wallet ${wallet}. Signature: ${logs.signature}, Slot: ${context.slot}`, { correlationId });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.callback(observation);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[RealWalletWatcher] Error in observation handler for signature ${logs.signature}`, error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'confirmed'
|
'confirmed'
|
||||||
);
|
);
|
||||||
this.subscriptionIds.push(subId);
|
const firstSig = initialSigs[0];
|
||||||
logger.info(`[RealWalletWatcher] Subscribed to logs for wallet ${wallet} (subId: ${subId})`);
|
if (firstSig) {
|
||||||
|
this.lastKnownSignatures.set(wallet, firstSig.signature);
|
||||||
|
logger.info(`[RealWalletWatcher] Wallet ${wallet} initial signature checkpoint: ${firstSig.signature}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`[RealWalletWatcher] Wallet ${wallet} has no previous transactions.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start adaptive recursive setTimeout polling loop
|
||||||
|
const pollWallet = async (currentDelay = 2000) => {
|
||||||
|
if (!this.active) return;
|
||||||
|
let nextDelay = currentDelay;
|
||||||
|
try {
|
||||||
|
const lastSig = this.lastKnownSignatures.get(wallet);
|
||||||
|
const options: any = { limit: 20 };
|
||||||
|
if (lastSig) {
|
||||||
|
options.until = lastSig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sigInfos = await this.connection!.getSignaturesForAddress(
|
||||||
|
pubkey,
|
||||||
|
options,
|
||||||
|
'confirmed'
|
||||||
|
);
|
||||||
|
|
||||||
|
// On success, slowly decay nextDelay back to default 2000ms
|
||||||
|
nextDelay = Math.max(2000, currentDelay - 500);
|
||||||
|
|
||||||
|
if (sigInfos.length > 0) {
|
||||||
|
// Update last known signature to the newest one (first item in response)
|
||||||
|
const newestSig = sigInfos[0];
|
||||||
|
if (newestSig) {
|
||||||
|
this.lastKnownSignatures.set(wallet, newestSig.signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process from oldest to newest
|
||||||
|
const newSigs = [...sigInfos].reverse();
|
||||||
|
|
||||||
|
for (const sigInfo of newSigs) {
|
||||||
|
if (!this.callback || !this.active) return;
|
||||||
|
|
||||||
|
const correlationId = randomUUID();
|
||||||
|
const observation: WalletTransactionObservation & { rawPayload?: any } = {
|
||||||
|
signature: sigInfo.signature,
|
||||||
|
slot: sigInfo.slot,
|
||||||
|
walletAddress: wallet,
|
||||||
|
timestamp: new Date(),
|
||||||
|
correlationId,
|
||||||
|
rawPayload: sigInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`[RealWalletWatcher] Activity detected for wallet ${wallet}. Signature: ${sigInfo.signature}, Slot: ${sigInfo.slot}`, { correlationId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.callback(observation);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[RealWalletWatcher] Error in observation handler for signature ${sigInfo.signature}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const isRateLimit = err && (err.status === 429 || String(err.message).includes('429') || String(err).includes('429'));
|
||||||
|
if (isRateLimit) {
|
||||||
|
nextDelay = Math.min(10000, currentDelay * 2);
|
||||||
|
logger.warn(`[RealWalletWatcher] HTTP 429 Rate Limit encountered during signatures poll for wallet ${wallet}. Backing off polling delay to ${nextDelay}ms.`);
|
||||||
|
} else {
|
||||||
|
logger.error(`[RealWalletWatcher] Error polling transactions for wallet ${wallet}`, err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (this.active) {
|
||||||
|
const timeoutId = setTimeout(() => pollWallet(nextDelay), nextDelay);
|
||||||
|
this.pollTimeouts.push(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the first poll
|
||||||
|
const timeoutId = setTimeout(() => pollWallet(2000), 2000);
|
||||||
|
this.pollTimeouts.push(timeoutId);
|
||||||
|
logger.info(`[RealWalletWatcher] Polling transaction signatures for wallet ${wallet} every 2000ms`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`[RealWalletWatcher] Failed to subscribe to logs for wallet ${wallet}`, err);
|
logger.error(`[RealWalletWatcher] Failed to initialize polling for wallet ${wallet}`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -69,17 +130,11 @@ export class RealWalletWatcher implements IWalletWatcher {
|
|||||||
async stopWatching(): Promise<void> {
|
async stopWatching(): Promise<void> {
|
||||||
this.active = false;
|
this.active = false;
|
||||||
logger.info('Stopping RealWalletWatcher...');
|
logger.info('Stopping RealWalletWatcher...');
|
||||||
if (this.connection) {
|
for (const timeoutId of this.pollTimeouts) {
|
||||||
for (const subId of this.subscriptionIds) {
|
clearTimeout(timeoutId);
|
||||||
try {
|
|
||||||
await this.connection.removeOnLogsListener(subId);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`[RealWalletWatcher] Error removing onLogs listener ${subId}`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.subscriptionIds = [];
|
|
||||||
this.connection = null;
|
|
||||||
}
|
}
|
||||||
|
this.pollTimeouts = [];
|
||||||
|
this.connection = null;
|
||||||
logger.info('Stopped RealWalletWatcher.');
|
logger.info('Stopped RealWalletWatcher.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import { randomUUID } from 'crypto';
|
|||||||
const mockOnLogs = vi.fn().mockReturnValue(12345);
|
const mockOnLogs = vi.fn().mockReturnValue(12345);
|
||||||
const mockRemoveOnLogsListener = vi.fn().mockResolvedValue(true);
|
const mockRemoveOnLogsListener = vi.fn().mockResolvedValue(true);
|
||||||
const mockGetTransaction = vi.fn();
|
const mockGetTransaction = vi.fn();
|
||||||
|
const mockGetSignaturesForAddress = vi.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
vi.mock('@solana/web3.js', () => {
|
vi.mock('@solana/web3.js', () => {
|
||||||
class MockConnection {
|
class MockConnection {
|
||||||
onLogs = mockOnLogs;
|
onLogs = mockOnLogs;
|
||||||
removeOnLogsListener = mockRemoveOnLogsListener;
|
removeOnLogsListener = mockRemoveOnLogsListener;
|
||||||
getTransaction = mockGetTransaction;
|
getTransaction = mockGetTransaction;
|
||||||
|
getSignaturesForAddress = mockGetSignaturesForAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockPublicKey {
|
class MockPublicKey {
|
||||||
@@ -96,26 +98,17 @@ describe('Real Solana Observation Mode Unit Tests', () => {
|
|||||||
|
|
||||||
// 2. RealWalletWatcher Subscription and Shutdown Behavior
|
// 2. RealWalletWatcher Subscription and Shutdown Behavior
|
||||||
describe('RealWalletWatcher Lifecycle & Reconnect Behavior', () => {
|
describe('RealWalletWatcher Lifecycle & Reconnect Behavior', () => {
|
||||||
it('should subscribe to logs for all watched wallets on startup', async () => {
|
it('should query signatures on startup to set checkpoint and poll periodically', async () => {
|
||||||
const wallets = ['3yF9asA9B7G3Y1G7as78gHJKa7A', '4yF9asA9B7G3Y1G7as78gHJKa7B'];
|
const wallets = ['3yF9asA9B7G3Y1G7as78gHJKa7A', '4yF9asA9B7G3Y1G7as78gHJKa7B'];
|
||||||
|
mockGetSignaturesForAddress.mockResolvedValue([
|
||||||
|
{ signature: 'sig1', slot: 100, err: null }
|
||||||
|
]);
|
||||||
const watcher = new RealWalletWatcher(wallets, 'http://dummy-rpc', 'wss://dummy-wss');
|
const watcher = new RealWalletWatcher(wallets, 'http://dummy-rpc', 'wss://dummy-wss');
|
||||||
|
|
||||||
await watcher.startWatching();
|
await watcher.startWatching();
|
||||||
|
|
||||||
expect(connection.onLogs).toHaveBeenCalledTimes(2);
|
expect(mockGetSignaturesForAddress).toHaveBeenCalled();
|
||||||
expect(connection.onLogs).toHaveBeenNthCalledWith(1, expect.any(Object), expect.any(Function), 'confirmed');
|
|
||||||
expect(connection.onLogs).toHaveBeenNthCalledWith(2, expect.any(Object), expect.any(Function), 'confirmed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should unsubscribe from all logs listeners on shutdown', async () => {
|
|
||||||
const wallets = ['3yF9asA9B7G3Y1G7as78gHJKa7A'];
|
|
||||||
const watcher = new RealWalletWatcher(wallets, 'http://dummy-rpc', 'wss://dummy-wss');
|
|
||||||
|
|
||||||
await watcher.startWatching();
|
|
||||||
await watcher.stopWatching();
|
await watcher.stopWatching();
|
||||||
|
|
||||||
expect(connection.removeOnLogsListener).toHaveBeenCalledTimes(1);
|
|
||||||
expect(connection.removeOnLogsListener).toHaveBeenCalledWith(12345);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -327,6 +320,9 @@ describe('Real Solana Observation Mode Unit Tests', () => {
|
|||||||
recordRpcRequestRate: vi.fn(),
|
recordRpcRequestRate: vi.fn(),
|
||||||
recordQueueProcessed: vi.fn(),
|
recordQueueProcessed: vi.fn(),
|
||||||
recordRpcFetchError: vi.fn(),
|
recordRpcFetchError: vi.fn(),
|
||||||
|
recordWalletState: vi.fn(),
|
||||||
|
recordWalletIncomingRate: vi.fn(),
|
||||||
|
recordWalletProcessedRate: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -724,6 +720,75 @@ describe('Real Solana Observation Mode Unit Tests', () => {
|
|||||||
loggerDebugSpy.mockRestore();
|
loggerDebugSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should correctly transition wallet state to HOT under overload and maintain NORMAL for others', async () => {
|
||||||
|
const { BoundedTransactionQueue } = await import('../src/solana/fetcher.js');
|
||||||
|
const queue = new BoundedTransactionQueue(
|
||||||
|
connection as any,
|
||||||
|
1, // maxConcurrency = 1
|
||||||
|
10, // minDelay = 10ms
|
||||||
|
5, // maxQueueSize = 5
|
||||||
|
mockMetrics,
|
||||||
|
);
|
||||||
|
queue.start();
|
||||||
|
|
||||||
|
// Push first item for walletA (goes active immediately)
|
||||||
|
queue.push({
|
||||||
|
signature: 'A1',
|
||||||
|
walletAddress: 'walletA',
|
||||||
|
slot: 100,
|
||||||
|
detectedAt: new Date(),
|
||||||
|
correlationId: 'corrA1',
|
||||||
|
onSuccess: async () => {},
|
||||||
|
onFailure: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// walletA gets 4 more items queued (total depth 4/5 = 80%, should trigger HOT)
|
||||||
|
for (let i = 2; i <= 5; i++) {
|
||||||
|
queue.push({
|
||||||
|
signature: `A${i}`,
|
||||||
|
walletAddress: 'walletA',
|
||||||
|
slot: 100,
|
||||||
|
detectedAt: new Date(),
|
||||||
|
correlationId: `corrA${i}`,
|
||||||
|
onSuccess: async () => {},
|
||||||
|
onFailure: async () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// walletB gets 1 item queued (depth 1/5, should stay NORMAL)
|
||||||
|
queue.push({
|
||||||
|
signature: 'B1',
|
||||||
|
walletAddress: 'walletB',
|
||||||
|
slot: 100,
|
||||||
|
detectedAt: new Date(),
|
||||||
|
correlationId: 'corrB1',
|
||||||
|
onSuccess: async () => {},
|
||||||
|
onFailure: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queue.getWalletState('walletA')).toBe('HOT');
|
||||||
|
expect(queue.getWalletState('walletB')).toBe('NORMAL');
|
||||||
|
|
||||||
|
queue.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pre-filter obviously irrelevant transactions using the lightweight log filter', async () => {
|
||||||
|
const { isLogIrrelevant } = await import('../src/solana/parser.js');
|
||||||
|
|
||||||
|
const irrelevantLogs = [
|
||||||
|
'Program Vote111111111111111111111111111111111111111 invoke [1]',
|
||||||
|
'Program Vote111111111111111111111111111111111111111 success',
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalLogs = [
|
||||||
|
'Program ComputeBudget111111111111111111111111111111 invoke [1]',
|
||||||
|
'Program swapInstruction invoke [2]',
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(isLogIrrelevant(irrelevantLogs)).toBe(true);
|
||||||
|
expect(isLogIrrelevant(normalLogs)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('should classify fetched transactions correctly based on SOL/SPL token balance changes', async () => {
|
it('should classify fetched transactions correctly based on SOL/SPL token balance changes', async () => {
|
||||||
const { isTransactionRelevant } = await import('../src/solana/parser.js');
|
const { isTransactionRelevant } = await import('../src/solana/parser.js');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user