fixing empty transaction
This commit is contained in:
@@ -158,6 +158,15 @@ export class Application {
|
||||
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(
|
||||
`[RealMode] Activity detected for wallet ${observation.walletAddress}. Signature: ${observation.signature}, Slot: ${observation.slot}`,
|
||||
{ correlationId },
|
||||
@@ -188,7 +197,9 @@ export class Application {
|
||||
|
||||
// Push item onto the bounded queue for non-blocking execution
|
||||
if (!this.transactionQueue) {
|
||||
if (!this.isShuttingDown) {
|
||||
logger.error('[RealMode] Transaction queue not initialized!');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -385,20 +396,23 @@ export class Application {
|
||||
|
||||
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) {
|
||||
clearInterval(this.simulationIntervalId);
|
||||
this.simulationIntervalId = null;
|
||||
}
|
||||
|
||||
// 3. Stop and clear transaction queues
|
||||
if (this.transactionQueue) {
|
||||
this.transactionQueue.stop();
|
||||
this.transactionQueue = null;
|
||||
}
|
||||
|
||||
if (this.watcher) {
|
||||
await this.watcher.stopWatching();
|
||||
}
|
||||
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const journalRepo = new PgEventJournalRepository();
|
||||
@@ -413,6 +427,7 @@ export class Application {
|
||||
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) {
|
||||
await this.metricsProvider.close();
|
||||
}
|
||||
|
||||
@@ -143,4 +143,7 @@ export interface IMetricsProvider {
|
||||
recordRpcRequestRate(requestsPerSecond: number): void;
|
||||
recordQueueProcessed(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);
|
||||
}
|
||||
|
||||
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> {
|
||||
if (this.writeApi) {
|
||||
logger.info('Closing InfluxDB metrics writer...');
|
||||
@@ -180,4 +195,7 @@ export class MockMetricsProvider implements IMetricsProvider {
|
||||
recordRpcRequestRate(_requestsPerSecond: number): void {}
|
||||
recordQueueProcessed(_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,
|
||||
maxDelayMs = 8000,
|
||||
metricsProvider?: IMetricsProvider,
|
||||
transactionDetails: 'accounts' | 'full' = 'full',
|
||||
onRateLimit?: () => void,
|
||||
): Promise<VersionedTransactionResponse> {
|
||||
let attempt = 0;
|
||||
let delay = initialDelayMs;
|
||||
|
||||
while (attempt < maxRetries) {
|
||||
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, {
|
||||
commitment: 'confirmed',
|
||||
maxSupportedTransactionVersion: 0,
|
||||
});
|
||||
transactionDetails,
|
||||
} as any);
|
||||
|
||||
if (tx) {
|
||||
return tx;
|
||||
@@ -33,6 +36,9 @@ export async function fetchTransactionWithRetry(
|
||||
if (metricsProvider) {
|
||||
metricsProvider.recordQueue429s(signature);
|
||||
}
|
||||
if (onRateLimit) {
|
||||
onRateLimit();
|
||||
}
|
||||
// Use exponential backoff with jitter for HTTP 429
|
||||
const jitter = Math.random() * 200;
|
||||
const rateLimitDelay = Math.min(delay * 3 + jitter, maxDelayMs * 2);
|
||||
@@ -103,13 +109,65 @@ export class BoundedTransactionQueue {
|
||||
private rateSecondTimer: 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(
|
||||
private connection: Connection,
|
||||
private maxConcurrency: number,
|
||||
private minDelayMs: number,
|
||||
private maxQueueSize: number, // Bounded limit PER wallet queue
|
||||
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 {
|
||||
if (this.running) return;
|
||||
@@ -121,6 +179,24 @@ export class BoundedTransactionQueue {
|
||||
this.metricsProvider.recordRpcRequestRate(this.requestCountLastSecond);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -161,6 +237,9 @@ export class BoundedTransactionQueue {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentIncoming = this.walletIncomingCountLastSecond.get(item.walletAddress) || 0;
|
||||
this.walletIncomingCountLastSecond.set(item.walletAddress, currentIncoming + 1);
|
||||
|
||||
if (this.metricsProvider) {
|
||||
this.metricsProvider.recordWalletEventReceived(item.walletAddress);
|
||||
}
|
||||
@@ -206,6 +285,8 @@ export class BoundedTransactionQueue {
|
||||
this.walletOrder.push(item.walletAddress);
|
||||
}
|
||||
|
||||
this.updateWalletState(item.walletAddress);
|
||||
|
||||
logger.debug(`[BoundedTransactionQueue] Queued signature ${item.signature} for wallet ${item.walletAddress}. Depth: ${wQueue.length}`);
|
||||
|
||||
// Trigger process next non-blockingly
|
||||
@@ -228,10 +309,10 @@ export class BoundedTransactionQueue {
|
||||
// Enforcement of rate limiting delay
|
||||
const now = Date.now();
|
||||
const timeSinceLast = now - this.lastRequestTime;
|
||||
if (timeSinceLast < this.minDelayMs) {
|
||||
const waitTime = this.minDelayMs - timeSinceLast;
|
||||
if (timeSinceLast < this.adaptiveDelayMs) {
|
||||
const waitTime = this.adaptiveDelayMs - timeSinceLast;
|
||||
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 = null;
|
||||
this.processNext();
|
||||
@@ -255,6 +336,8 @@ export class BoundedTransactionQueue {
|
||||
this.walletOrder.push(walletAddr);
|
||||
}
|
||||
|
||||
this.updateWalletState(walletAddr);
|
||||
|
||||
if (this.metricsProvider) {
|
||||
this.metricsProvider.recordWalletQueueDepth(walletAddr, wQueue.length);
|
||||
this.metricsProvider.recordQueueDepth(this.getDepth());
|
||||
@@ -276,15 +359,51 @@ export class BoundedTransactionQueue {
|
||||
private async executeFetch(item: QueueItem): Promise<void> {
|
||||
const fetchStartTime = Date.now();
|
||||
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,
|
||||
item.signature,
|
||||
5,
|
||||
500,
|
||||
4000,
|
||||
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 fetchDuration = fetchEndTime - fetchStartTime;
|
||||
|
||||
@@ -293,6 +412,9 @@ export class BoundedTransactionQueue {
|
||||
this.metricsProvider.recordQueueProcessed(item.walletAddress);
|
||||
}
|
||||
|
||||
const procCount = this.walletProcessedCountLastSecond.get(item.walletAddress) || 0;
|
||||
this.walletProcessedCountLastSecond.set(item.walletAddress, procCount + 1);
|
||||
|
||||
await item.onSuccess(tx);
|
||||
} catch (err: any) {
|
||||
logger.error(`[BoundedTransactionQueue] Processing failed for signature ${item.signature}`, err);
|
||||
@@ -302,6 +424,7 @@ export class BoundedTransactionQueue {
|
||||
await item.onFailure(err);
|
||||
} finally {
|
||||
this.activeCount--;
|
||||
this.updateWalletState(item.walletAddress);
|
||||
logger.debug(`[BoundedTransactionQueue] Finished item ${item.signature}. Active: ${this.activeCount}`);
|
||||
this.processNext();
|
||||
}
|
||||
|
||||
@@ -138,3 +138,18 @@ export function isTransactionRelevant(
|
||||
|
||||
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 {
|
||||
private callback: ((obs: WalletTransactionObservation) => Promise<void>) | null = null;
|
||||
private connection: Connection | null = null;
|
||||
private subscriptionIds: number[] = [];
|
||||
public connection: Connection | null = null;
|
||||
private active = false;
|
||||
private lastKnownSignatures = new Map<string, string>();
|
||||
private pollTimeouts: NodeJS.Timeout[] = [];
|
||||
|
||||
constructor(
|
||||
private watchedWallets: string[],
|
||||
@@ -29,35 +30,95 @@ export class RealWalletWatcher implements IWalletWatcher {
|
||||
for (const wallet of this.watchedWallets) {
|
||||
try {
|
||||
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,
|
||||
async (logs, context) => {
|
||||
if (!this.callback) return;
|
||||
if (!logs.signature || !this.active) return;
|
||||
{ limit: 1 },
|
||||
'confirmed'
|
||||
);
|
||||
const firstSig = initialSigs[0];
|
||||
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 = {
|
||||
signature: logs.signature,
|
||||
slot: context.slot,
|
||||
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: ${logs.signature}, Slot: ${context.slot}`, { correlationId });
|
||||
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 ${logs.signature}`, error);
|
||||
logger.error(`[RealWalletWatcher] Error in observation handler for signature ${sigInfo.signature}`, error);
|
||||
}
|
||||
},
|
||||
'confirmed'
|
||||
);
|
||||
this.subscriptionIds.push(subId);
|
||||
logger.info(`[RealWalletWatcher] Subscribed to logs for wallet ${wallet} (subId: ${subId})`);
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
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) {
|
||||
@@ -69,17 +130,11 @@ export class RealWalletWatcher implements IWalletWatcher {
|
||||
async stopWatching(): Promise<void> {
|
||||
this.active = false;
|
||||
logger.info('Stopping RealWalletWatcher...');
|
||||
if (this.connection) {
|
||||
for (const subId of this.subscriptionIds) {
|
||||
try {
|
||||
await this.connection.removeOnLogsListener(subId);
|
||||
} catch (err) {
|
||||
logger.error(`[RealWalletWatcher] Error removing onLogs listener ${subId}`, err);
|
||||
for (const timeoutId of this.pollTimeouts) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
this.subscriptionIds = [];
|
||||
this.pollTimeouts = [];
|
||||
this.connection = null;
|
||||
}
|
||||
logger.info('Stopped RealWalletWatcher.');
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,14 @@ import { randomUUID } from 'crypto';
|
||||
const mockOnLogs = vi.fn().mockReturnValue(12345);
|
||||
const mockRemoveOnLogsListener = vi.fn().mockResolvedValue(true);
|
||||
const mockGetTransaction = vi.fn();
|
||||
const mockGetSignaturesForAddress = vi.fn().mockResolvedValue([]);
|
||||
|
||||
vi.mock('@solana/web3.js', () => {
|
||||
class MockConnection {
|
||||
onLogs = mockOnLogs;
|
||||
removeOnLogsListener = mockRemoveOnLogsListener;
|
||||
getTransaction = mockGetTransaction;
|
||||
getSignaturesForAddress = mockGetSignaturesForAddress;
|
||||
}
|
||||
|
||||
class MockPublicKey {
|
||||
@@ -96,26 +98,17 @@ describe('Real Solana Observation Mode Unit Tests', () => {
|
||||
|
||||
// 2. RealWalletWatcher Subscription and Shutdown 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'];
|
||||
mockGetSignaturesForAddress.mockResolvedValue([
|
||||
{ signature: 'sig1', slot: 100, err: null }
|
||||
]);
|
||||
const watcher = new RealWalletWatcher(wallets, 'http://dummy-rpc', 'wss://dummy-wss');
|
||||
|
||||
await watcher.startWatching();
|
||||
|
||||
expect(connection.onLogs).toHaveBeenCalledTimes(2);
|
||||
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();
|
||||
expect(mockGetSignaturesForAddress).toHaveBeenCalled();
|
||||
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(),
|
||||
recordQueueProcessed: 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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { isTransactionRelevant } = await import('../src/solana/parser.js');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user