diff --git a/src/application/index.ts b/src/application/index.ts index 0506c2f..3dc6cf8 100644 --- a/src/application/index.ts +++ b/src/application/index.ts @@ -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) { - logger.error('[RealMode] Transaction queue not initialized!'); + 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(); } diff --git a/src/domain/interfaces.ts b/src/domain/interfaces.ts index fb9edf3..2a29ba4 100644 --- a/src/domain/interfaces.ts +++ b/src/domain/interfaces.ts @@ -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; } diff --git a/src/metrics/index.ts b/src/metrics/index.ts index 9f073dd..de98f51 100644 --- a/src/metrics/index.ts +++ b/src/metrics/index.ts @@ -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 { 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 {} } diff --git a/src/solana/fetcher.ts b/src/solana/fetcher.ts index 274fde8..1c8e920 100644 --- a/src/solana/fetcher.ts +++ b/src/solana/fetcher.ts @@ -9,17 +9,20 @@ export async function fetchTransactionWithRetry( initialDelayMs = 500, maxDelayMs = 8000, metricsProvider?: IMetricsProvider, + transactionDetails: 'accounts' | 'full' = 'full', + onRateLimit?: () => void, ): Promise { 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(); + private walletProcessedCountLastSecond = new Map(); + private walletIncomingRate = new Map(); + private walletProcessedRate = new Map(); + private walletStates = new Map(); + + // 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 { 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(); } diff --git a/src/solana/parser.ts b/src/solana/parser.ts index 33c21d6..023acd4 100644 --- a/src/solana/parser.ts +++ b/src/solana/parser.ts @@ -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; +} diff --git a/src/solana/watcher.ts b/src/solana/watcher.ts index 8c711a6..61861a9 100644 --- a/src/solana/watcher.ts +++ b/src/solana/watcher.ts @@ -5,9 +5,10 @@ import { logger } from '../logging/index.js'; export class RealWalletWatcher implements IWalletWatcher { private callback: ((obs: WalletTransactionObservation) => Promise) | null = null; - private connection: Connection | null = null; - private subscriptionIds: number[] = []; + public connection: Connection | null = null; private active = false; + private lastKnownSignatures = new Map(); + 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; - - 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); - } - }, + { limit: 1 }, 'confirmed' ); - this.subscriptionIds.push(subId); - logger.info(`[RealWalletWatcher] Subscribed to logs for wallet ${wallet} (subId: ${subId})`); + 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 & { 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) { - 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 { 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); - } - } - this.subscriptionIds = []; - this.connection = null; + for (const timeoutId of this.pollTimeouts) { + clearTimeout(timeoutId); } + this.pollTimeouts = []; + this.connection = null; logger.info('Stopped RealWalletWatcher.'); } diff --git a/tests/realMode.test.ts b/tests/realMode.test.ts index cabf16f..f038d60 100644 --- a/tests/realMode.test.ts +++ b/tests/realMode.test.ts @@ -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');