diff --git a/src/application/index.ts b/src/application/index.ts index c11226f..3061ddb 100644 --- a/src/application/index.ts +++ b/src/application/index.ts @@ -9,19 +9,23 @@ import { PgSimulationExecutionRepository, PgEventJournalRepository, PgStrategyRunsRepository, + PgRawWalletTransactionRepository, } from '../persistence/repositories.js'; import { EventJournalService } from '../persistence/eventJournal.js'; import { InfluxMetricsProvider, MockMetricsProvider } from '../metrics/index.js'; import { DeterministicMarketDataProvider } from '../simulation/marketData.js'; import { SimpleCopyStrategy } from '../strategy/index.js'; -import { MockWalletWatcher } from '../solana/watcher.js'; +import { MockWalletWatcher, RealWalletWatcher } from '../solana/watcher.js'; import { SimulationScheduler } from '../simulation/scheduler.js'; import { SignalNormalizer } from '../signals/index.js'; +import { IWalletWatcher } from '../domain/interfaces.js'; +import { RawWalletTransaction } from '../domain/models.js'; +import { fetchTransactionWithRetry } from '../solana/fetcher.js'; export class Application { public isShuttingDown = false; public simulationIntervalId: NodeJS.Timeout | null = null; - public watcher: MockWalletWatcher | null = null; + public watcher: IWalletWatcher | null = null; public metricsProvider: InfluxMetricsProvider | MockMetricsProvider | null = null; async bootstrap(): Promise { @@ -62,6 +66,7 @@ export class Application { const execRepo = new PgSimulationExecutionRepository(); const journalRepo = new PgEventJournalRepository(); const strategyRunsRepo = new PgStrategyRunsRepository(); + const rawTxRepo = new PgRawWalletTransactionRepository(); const journalService = new EventJournalService( journalRepo, @@ -120,11 +125,123 @@ export class Application { const watchedWalletsFromDb = await walletRepo.getWatchedWallets(); const watchedAddresses = watchedWalletsFromDb.map((w) => w.address); - this.watcher = new MockWalletWatcher(watchedAddresses); + if (config.WALLET_WATCHER_MODE === 'real') { + this.watcher = new RealWalletWatcher( + watchedAddresses, + config.SOLANA_RPC_ENDPOINT, + config.SOLANA_WSS_ENDPOINT, + ); + } else { + this.watcher = new MockWalletWatcher(watchedAddresses); + } // 6. Core Observation Pipeline Setup this.watcher.onObservation(async (observation) => { - const correlationId = randomUUID(); + const correlationId = observation.correlationId || randomUUID(); + const detectedAt = observation.timestamp || new Date(); + + if (config.WALLET_WATCHER_MODE === 'real') { + logger.info( + `[RealMode] Activity detected for wallet ${observation.walletAddress}. Signature: ${observation.signature}, Slot: ${observation.slot}`, + { correlationId }, + ); + + // Check duplicate first + const existing = await rawTxRepo.getTransactionBySignature(observation.signature); + if (existing) { + logger.info(`[RealMode] Duplicate notification for signature ${observation.signature}, ignoring.`, { correlationId }); + await journalService.log(correlationId, 'duplicate_transaction_ignored', { + signature: observation.signature, + walletAddress: observation.walletAddress, + }); + return; + } + + await journalService.log(correlationId, 'wallet_activity_detected', { + walletAddress: observation.walletAddress, + signature: observation.signature, + slot: observation.slot, + detectedAt, + }); + + this.metricsProvider?.recordWalletDetectionLatency( + Date.now() - detectedAt.getTime(), + observation.walletAddress, + ); + + // Fetch transaction + const fetchStartTime = Date.now(); + await journalService.log(correlationId, 'raw_transaction_fetch_started', { + signature: observation.signature, + }); + + let txPayload: any; + try { + const connection = (this.watcher as any).connection; + if (!connection) { + throw new Error('Connection not initialized on RealWalletWatcher'); + } + txPayload = await fetchTransactionWithRetry(connection, observation.signature); + } catch (error) { + logger.error(`[RealMode] Failed to fetch transaction ${observation.signature}`, error); + await journalService.log(correlationId, 'raw_transaction_fetch_failed', { + signature: observation.signature, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + const fetchEndTime = Date.now(); + const fetchDuration = fetchEndTime - fetchStartTime; + this.metricsProvider?.recordQuoteLatency(fetchDuration, 'rpc_fetch'); + + await journalService.log(correlationId, 'raw_transaction_fetched', { + signature: observation.signature, + slot: txPayload.slot, + blockTime: txPayload.blockTime, + }); + + // Persist raw transaction + try { + const blockTime = txPayload.blockTime ? new Date(txPayload.blockTime * 1000) : null; + + if (blockTime) { + this.metricsProvider?.recordWalletDetectionLatency( + Date.now() - blockTime.getTime(), + observation.walletAddress, + ); + } + + const rawTx: RawWalletTransaction = { + transactionSignature: observation.signature, + sourceWallet: observation.walletAddress, + slot: observation.slot, + blockTime, + detectedAt, + rawPayload: txPayload, + parserVersion: 'ingestion_v1', + }; + + await rawTxRepo.saveTransaction(rawTx); + + const totalDuration = Date.now() - detectedAt.getTime(); + this.metricsProvider?.recordSignalProcessingLatency(totalDuration); + + await journalService.log(correlationId, 'raw_transaction_persisted', { + signature: observation.signature, + slot: observation.slot, + }); + + logger.info(`[RealMode] Raw transaction ${observation.signature} successfully persisted and pipeline STOPPED.`, { correlationId }); + } catch (error) { + logger.error(`[RealMode] Failed to persist raw transaction ${observation.signature}`, error); + } + + // STOP (No strategy evaluation, scheduler or copy signal creation in real mode!) + return; + } + + // Mock Mode pipeline const startTime = Date.now(); logger.info( @@ -135,10 +252,10 @@ export class Application { await journalService.log(correlationId, 'wallet_tx_detected', { walletAddress: observation.walletAddress, signature: observation.signature, - tokenMint: observation.tokenMint, - side: observation.side, - amountSol: observation.amountSol, - tokenAmount: observation.tokenAmount, + tokenMint: observation.tokenMint!, + side: observation.side!, + amountSol: observation.amountSol || null, + tokenAmount: observation.tokenAmount || null, detectedSlot: observation.slot, }); @@ -148,7 +265,7 @@ export class Application { ); // Retrieve quote for current price - const currentQuote = await marketData.getQuote(observation.tokenMint, observation.timestamp); + const currentQuote = await marketData.getQuote(observation.tokenMint!, observation.timestamp); // Normalize copy signal const signal = normalizer.normalize(observation, currentQuote.priceSol, correlationId); diff --git a/src/domain/interfaces.ts b/src/domain/interfaces.ts index 453ac80..3c4fe8e 100644 --- a/src/domain/interfaces.ts +++ b/src/domain/interfaces.ts @@ -23,11 +23,12 @@ export interface WalletTransactionObservation { signature: string; slot: number; walletAddress: string; - tokenMint: string; - side: 'buy' | 'sell'; - amountSol: number | null; - tokenAmount: number | null; + tokenMint?: string | null; + side?: 'buy' | 'sell' | null; + amountSol?: number | null; + tokenAmount?: number | null; timestamp: Date; + correlationId?: string; } export interface IWalletWatcher { diff --git a/src/signals/index.ts b/src/signals/index.ts index 71304da..eacd68e 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -16,14 +16,14 @@ export class SignalNormalizer implements ISignalNormalizer { id: randomUUID(), correlationId, sourceWallet: observation.walletAddress, - tokenMint: observation.tokenMint, - side: observation.side, + tokenMint: observation.tokenMint!, + side: observation.side!, sourceTransactionSignature: observation.signature, sourceTransactionTimestamp: observation.timestamp, detectedTimestamp: new Date(), detectedSlot: observation.slot, - sourceAmountSol: observation.amountSol, - tokenAmount: observation.tokenAmount, + sourceAmountSol: observation.amountSol ?? null, + tokenAmount: observation.tokenAmount ?? null, sourcePrice: sourcePriceSol, metadata: { sourceSlot: observation.slot, diff --git a/src/solana/fetcher.ts b/src/solana/fetcher.ts new file mode 100644 index 0000000..27b09af --- /dev/null +++ b/src/solana/fetcher.ts @@ -0,0 +1,42 @@ +import { Connection, VersionedTransactionResponse } from '@solana/web3.js'; +import { logger } from '../logging/index.js'; + +export async function fetchTransactionWithRetry( + connection: Connection, + signature: string, + maxRetries = 5, + initialDelayMs = 500, + maxDelayMs = 4000, +): Promise { + let attempt = 0; + let delay = initialDelayMs; + + while (attempt < maxRetries) { + try { + logger.debug(`[TransactionFetcher] Fetching transaction ${signature} (attempt ${attempt + 1}/${maxRetries})...`); + const tx = await connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + + if (tx) { + return tx; + } + + logger.warn(`[TransactionFetcher] Transaction ${signature} not found on attempt ${attempt + 1}. Retrying...`); + } catch (err) { + logger.error(`[TransactionFetcher] Error fetching transaction ${signature} on attempt ${attempt + 1}`, err); + } + + attempt++; + if (attempt >= maxRetries) { + break; + } + + logger.debug(`[TransactionFetcher] Waiting ${delay}ms before next retry for ${signature}...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.min(delay * 2, maxDelayMs); + } + + throw new Error(`Transaction ${signature} could not be fetched after ${maxRetries} attempts.`); +} diff --git a/src/solana/watcher.ts b/src/solana/watcher.ts index d47a848..8c711a6 100644 --- a/src/solana/watcher.ts +++ b/src/solana/watcher.ts @@ -1,6 +1,93 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import { randomUUID } from 'crypto'; import { IWalletWatcher, WalletTransactionObservation } from '../domain/interfaces.js'; 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[] = []; + private active = false; + + constructor( + private watchedWallets: string[], + private rpcEndpoint: string, + private wssEndpoint: string, + ) {} + + async startWatching(): Promise { + if (this.active) return; + this.active = true; + logger.info(`Starting RealWalletWatcher on wallets: ${this.watchedWallets.join(', ')}`); + + try { + this.connection = new Connection(this.rpcEndpoint, { + wsEndpoint: this.wssEndpoint, + commitment: 'confirmed', + }); + + for (const wallet of this.watchedWallets) { + try { + const pubkey = new PublicKey(wallet); + const subId = this.connection.onLogs( + 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); + } + }, + 'confirmed' + ); + this.subscriptionIds.push(subId); + logger.info(`[RealWalletWatcher] Subscribed to logs for wallet ${wallet} (subId: ${subId})`); + } catch (err) { + logger.error(`[RealWalletWatcher] Failed to subscribe to logs for wallet ${wallet}`, err); + } + } + } catch (err) { + logger.error('[RealWalletWatcher] Failed to initialize Solana Connection', err); + throw err; + } + } + + 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; + } + logger.info('Stopped RealWalletWatcher.'); + } + + onObservation(callback: (obs: WalletTransactionObservation) => Promise): void { + this.callback = callback; + } +} + export class MockWalletWatcher implements IWalletWatcher { private callback: ((obs: WalletTransactionObservation) => Promise) | null = null; private intervalId: NodeJS.Timeout | null = null; diff --git a/tests/mocks/InMemoryRepositories.ts b/tests/mocks/InMemoryRepositories.ts index 7fb754e..4e94667 100644 --- a/tests/mocks/InMemoryRepositories.ts +++ b/tests/mocks/InMemoryRepositories.ts @@ -4,6 +4,7 @@ import { ITradeRepository, ISimulationExecutionRepository, IEventJournalRepository, + IRawWalletTransactionRepository, } from '../../src/domain/interfaces.js'; import { WatchedWallet, @@ -11,6 +12,7 @@ import { Trade, SimulatedExecution, EventJournalEntry, + RawWalletTransaction, } from '../../src/domain/models.js'; export class InMemoryWalletRepository implements IWalletRepository { @@ -132,3 +134,18 @@ export class InMemoryStrategyRunsRepository { this.runs.push(run); } } + +export class InMemoryRawWalletTransactionRepository implements IRawWalletTransactionRepository { + public transactions: RawWalletTransaction[] = []; + + async saveTransaction(tx: RawWalletTransaction): Promise { + const existing = this.transactions.find((t) => t.transactionSignature === tx.transactionSignature); + if (!existing) { + this.transactions.push(tx); + } + } + + async getTransactionBySignature(signature: string): Promise { + return this.transactions.find((t) => t.transactionSignature === signature) || null; + } +} diff --git a/tests/realMode.test.ts b/tests/realMode.test.ts new file mode 100644 index 0000000..3053580 --- /dev/null +++ b/tests/realMode.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Connection } from '@solana/web3.js'; +import { RealWalletWatcher } from '../src/solana/watcher.js'; +import { fetchTransactionWithRetry } from '../src/solana/fetcher.js'; +import { + InMemoryRawWalletTransactionRepository, + InMemoryEventJournalRepository, +} from './mocks/InMemoryRepositories.js'; +import { EventJournalService } from '../src/persistence/eventJournal.js'; +import { WalletTransactionObservation } from '../src/domain/interfaces.js'; +import { RawWalletTransaction } from '../src/domain/models.js'; +import { randomUUID } from 'crypto'; + +// Mock @solana/web3.js references starting with "mock" to be accessible in hoisted vi.mock +const mockOnLogs = vi.fn().mockReturnValue(12345); +const mockRemoveOnLogsListener = vi.fn().mockResolvedValue(true); +const mockGetTransaction = vi.fn(); + +vi.mock('@solana/web3.js', () => { + class MockConnection { + onLogs = mockOnLogs; + removeOnLogsListener = mockRemoveOnLogsListener; + getTransaction = mockGetTransaction; + } + + class MockPublicKey { + constructor(public address: string) {} + toBase58() { + return this.address; + } + } + + return { + Connection: MockConnection, + PublicKey: MockPublicKey, + }; +}); + +describe('Real Solana Observation Mode Unit Tests', () => { + let connection: any; + + beforeEach(() => { + vi.clearAllMocks(); + connection = new Connection('http://dummy', 'confirmed'); + }); + + // 1. Transaction fetch retry with bounded exponential backoff + describe('Transaction Fetch Retry Mechanism', () => { + it('should successfully return transaction if it is found on the first attempt', async () => { + const mockTx = { slot: 12345, blockTime: 1710000000 }; + connection.getTransaction.mockResolvedValueOnce(mockTx); + + const result = await fetchTransactionWithRetry(connection as any, 'dummy-sig', 3, 10, 20); + expect(result).toEqual(mockTx); + expect(connection.getTransaction).toHaveBeenCalledTimes(1); + }); + + it('should retry fetching with backoff and succeed if transaction becomes available eventually', async () => { + const mockTx = { slot: 12345, blockTime: 1710000000 }; + connection.getTransaction + .mockResolvedValueOnce(null) // first attempt: temporarily unavailable + .mockRejectedValueOnce(new Error('RPC limit reached')) // second attempt: error + .mockResolvedValueOnce(mockTx); // third attempt: success + + const result = await fetchTransactionWithRetry(connection as any, 'dummy-sig', 4, 10, 50); + expect(result).toEqual(mockTx); + expect(connection.getTransaction).toHaveBeenCalledTimes(3); + }); + + it('should stop retrying and throw error if maxRetries limit is reached', async () => { + connection.getTransaction.mockResolvedValue(null); // always returns null + + await expect( + fetchTransactionWithRetry(connection as any, 'dummy-sig', 3, 5, 10) + ).rejects.toThrow('Transaction dummy-sig could not be fetched after 3 attempts.'); + expect(connection.getTransaction).toHaveBeenCalledTimes(3); + }); + }); + + // 2. RealWalletWatcher Subscription and Shutdown Behavior + describe('RealWalletWatcher Lifecycle & Reconnect Behavior', () => { + it('should subscribe to logs for all watched wallets on startup', async () => { + const wallets = ['3yF9asA9B7G3Y1G7as78gHJKa7A', '4yF9asA9B7G3Y1G7as78gHJKa7B']; + 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(); + await watcher.stopWatching(); + + expect(connection.removeOnLogsListener).toHaveBeenCalledTimes(1); + expect(connection.removeOnLogsListener).toHaveBeenCalledWith(12345); + }); + }); + + // 3. Real Mode Pipeline orchestration, Raw Persistence, Duplicates, and Stopping + describe('Real Mode Pipeline Logic', () => { + let rawTxRepo: InMemoryRawWalletTransactionRepository; + let journalRepo: InMemoryEventJournalRepository; + let journalService: EventJournalService; + + beforeEach(() => { + rawTxRepo = new InMemoryRawWalletTransactionRepository(); + journalRepo = new InMemoryEventJournalRepository(); + journalService = new EventJournalService(journalRepo, '1.0.0', 'commit', 'hash'); + }); + + // Simulated callback registration helper replicating Application real-mode callback + const runPipelineCallback = async ( + observation: WalletTransactionObservation, + watcherInstance: any, + ) => { + const correlationId = observation.correlationId || randomUUID(); + const detectedAt = observation.timestamp || new Date(); + + // Check duplicate first + const existing = await rawTxRepo.getTransactionBySignature(observation.signature); + if (existing) { + await journalService.log(correlationId, 'duplicate_transaction_ignored', { + signature: observation.signature, + walletAddress: observation.walletAddress, + }); + return { stopped: true, reason: 'duplicate' }; + } + + await journalService.log(correlationId, 'wallet_activity_detected', { + walletAddress: observation.walletAddress, + signature: observation.signature, + slot: observation.slot, + detectedAt, + }); + + // Fetch transaction + await journalService.log(correlationId, 'raw_transaction_fetch_started', { + signature: observation.signature, + }); + + let txPayload: any; + try { + txPayload = await fetchTransactionWithRetry(watcherInstance.connection, observation.signature, 3, 5, 10); + } catch (error) { + await journalService.log(correlationId, 'raw_transaction_fetch_failed', { + signature: observation.signature, + error: error instanceof Error ? error.message : String(error), + }); + return { stopped: true, reason: 'fetch_failed' }; + } + + await journalService.log(correlationId, 'raw_transaction_fetched', { + signature: observation.signature, + slot: txPayload.slot, + blockTime: txPayload.blockTime, + }); + + // Persist raw transaction + try { + const blockTime = txPayload.blockTime ? new Date(txPayload.blockTime * 1000) : null; + const rawTx: RawWalletTransaction = { + transactionSignature: observation.signature, + sourceWallet: observation.walletAddress, + slot: observation.slot, + blockTime, + detectedAt, + rawPayload: txPayload, + parserVersion: 'ingestion_v1', + }; + + await rawTxRepo.saveTransaction(rawTx); + + await journalService.log(correlationId, 'raw_transaction_persisted', { + signature: observation.signature, + slot: observation.slot, + }); + } catch { + // failed to persist + } + + // STOP: The pipeline must return here and NOT trigger strategy or simulation + return { stopped: true, reason: 'pipeline_stopped_normally' }; + }; + + it('should fetch, persist raw transaction, log events to journal, and STOP', async () => { + const observation: WalletTransactionObservation = { + signature: 'tx-sig-123', + slot: 100, + walletAddress: '3yF9asA9B7G3Y1G7as78gHJKa7A', + timestamp: new Date(), + correlationId: 'corr-real-mode', + }; + + const mockTxPayload = { + slot: 100, + blockTime: Math.floor(Date.now() / 1000), + meta: { logMessages: ['program instruction...'] }, + }; + connection.getTransaction.mockResolvedValueOnce(mockTxPayload); + + const watcher = new RealWalletWatcher(['3yF9asA9B7G3Y1G7as78gHJKa7A'], 'http://dummy', 'wss://dummy'); + (watcher as any).connection = connection; // inject connection mock + + const res = await runPipelineCallback(observation, watcher); + + // Verify the pipeline stopped normally + expect(res.stopped).toBe(true); + expect(res.reason).toBe('pipeline_stopped_normally'); + + // Verify the raw transaction was saved + const savedTx = await rawTxRepo.getTransactionBySignature('tx-sig-123'); + expect(savedTx).not.toBeNull(); + expect(savedTx?.transactionSignature).toBe('tx-sig-123'); + expect(savedTx?.sourceWallet).toBe('3yF9asA9B7G3Y1G7as78gHJKa7A'); + expect(savedTx?.rawPayload).toEqual(mockTxPayload); + + // Verify Event Journal logs + const events = await journalRepo.getEventsByCorrelationId('corr-real-mode'); + const eventTypes = events.map((e) => e.eventType); + expect(eventTypes).toContain('wallet_activity_detected'); + expect(eventTypes).toContain('raw_transaction_fetch_started'); + expect(eventTypes).toContain('raw_transaction_fetched'); + expect(eventTypes).toContain('raw_transaction_persisted'); + }); + + it('should ignore and log duplicate websocket notifications without calling RPC fetch again', async () => { + const observation: WalletTransactionObservation = { + signature: 'tx-sig-dup', + slot: 100, + walletAddress: '3yF9asA9B7G3Y1G7as78gHJKa7A', + timestamp: new Date(), + correlationId: 'corr-real-mode-dup', + }; + + // Pre-populate raw persistence to simulate duplicate + await rawTxRepo.saveTransaction({ + transactionSignature: 'tx-sig-dup', + sourceWallet: '3yF9asA9B7G3Y1G7as78gHJKa7A', + slot: 100, + blockTime: null, + detectedAt: new Date(), + rawPayload: {}, + parserVersion: 'ingestion_v1', + }); + + const watcher = new RealWalletWatcher(['3yF9asA9B7G3Y1G7as78gHJKa7A'], 'http://dummy', 'wss://dummy'); + (watcher as any).connection = connection; + + const res = await runPipelineCallback(observation, watcher); + + // Verify pipeline stopped + expect(res.stopped).toBe(true); + expect(res.reason).toBe('duplicate'); + + // Verify connection.getTransaction was NEVER called (saving RPC rate limit!) + expect(connection.getTransaction).not.toHaveBeenCalled(); + + // Verify duplicate event is written to journal + const events = await journalRepo.getEventsByCorrelationId('corr-real-mode-dup'); + expect(events.map((e) => e.eventType)).toContain('duplicate_transaction_ignored'); + }); + + it('should log raw_transaction_fetch_failed if RPC continues to fail and return gracefully', async () => { + const observation: WalletTransactionObservation = { + signature: 'tx-sig-fail', + slot: 100, + walletAddress: '3yF9asA9B7G3Y1G7as78gHJKa7A', + timestamp: new Date(), + correlationId: 'corr-real-mode-fail', + }; + + connection.getTransaction.mockResolvedValue(null); // simulates always failing to fetch + + const watcher = new RealWalletWatcher(['3yF9asA9B7G3Y1G7as78gHJKa7A'], 'http://dummy', 'wss://dummy'); + (watcher as any).connection = connection; + + const res = await runPipelineCallback(observation, watcher); + + // Verify pipeline stopped on failure + expect(res.stopped).toBe(true); + expect(res.reason).toBe('fetch_failed'); + + // Verify fetch failed event is in journal + const events = await journalRepo.getEventsByCorrelationId('corr-real-mode-fail'); + expect(events.map((e) => e.eventType)).toContain('raw_transaction_fetch_failed'); + }); + }); +});