Add real Solana wallet observation and raw transaction persistence
This commit is contained in:
@@ -9,19 +9,23 @@ import {
|
|||||||
PgSimulationExecutionRepository,
|
PgSimulationExecutionRepository,
|
||||||
PgEventJournalRepository,
|
PgEventJournalRepository,
|
||||||
PgStrategyRunsRepository,
|
PgStrategyRunsRepository,
|
||||||
|
PgRawWalletTransactionRepository,
|
||||||
} from '../persistence/repositories.js';
|
} from '../persistence/repositories.js';
|
||||||
import { EventJournalService } from '../persistence/eventJournal.js';
|
import { EventJournalService } from '../persistence/eventJournal.js';
|
||||||
import { InfluxMetricsProvider, MockMetricsProvider } from '../metrics/index.js';
|
import { InfluxMetricsProvider, MockMetricsProvider } from '../metrics/index.js';
|
||||||
import { DeterministicMarketDataProvider } from '../simulation/marketData.js';
|
import { DeterministicMarketDataProvider } from '../simulation/marketData.js';
|
||||||
import { SimpleCopyStrategy } from '../strategy/index.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 { SimulationScheduler } from '../simulation/scheduler.js';
|
||||||
import { SignalNormalizer } from '../signals/index.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 {
|
export class Application {
|
||||||
public isShuttingDown = false;
|
public isShuttingDown = false;
|
||||||
public simulationIntervalId: NodeJS.Timeout | null = null;
|
public simulationIntervalId: NodeJS.Timeout | null = null;
|
||||||
public watcher: MockWalletWatcher | null = null;
|
public watcher: IWalletWatcher | null = null;
|
||||||
public metricsProvider: InfluxMetricsProvider | MockMetricsProvider | null = null;
|
public metricsProvider: InfluxMetricsProvider | MockMetricsProvider | null = null;
|
||||||
|
|
||||||
async bootstrap(): Promise<void> {
|
async bootstrap(): Promise<void> {
|
||||||
@@ -62,6 +66,7 @@ export class Application {
|
|||||||
const execRepo = new PgSimulationExecutionRepository();
|
const execRepo = new PgSimulationExecutionRepository();
|
||||||
const journalRepo = new PgEventJournalRepository();
|
const journalRepo = new PgEventJournalRepository();
|
||||||
const strategyRunsRepo = new PgStrategyRunsRepository();
|
const strategyRunsRepo = new PgStrategyRunsRepository();
|
||||||
|
const rawTxRepo = new PgRawWalletTransactionRepository();
|
||||||
|
|
||||||
const journalService = new EventJournalService(
|
const journalService = new EventJournalService(
|
||||||
journalRepo,
|
journalRepo,
|
||||||
@@ -120,11 +125,123 @@ export class Application {
|
|||||||
const watchedWalletsFromDb = await walletRepo.getWatchedWallets();
|
const watchedWalletsFromDb = await walletRepo.getWatchedWallets();
|
||||||
const watchedAddresses = watchedWalletsFromDb.map((w) => w.address);
|
const watchedAddresses = watchedWalletsFromDb.map((w) => w.address);
|
||||||
|
|
||||||
|
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);
|
this.watcher = new MockWalletWatcher(watchedAddresses);
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Core Observation Pipeline Setup
|
// 6. Core Observation Pipeline Setup
|
||||||
this.watcher.onObservation(async (observation) => {
|
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();
|
const startTime = Date.now();
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -135,10 +252,10 @@ export class Application {
|
|||||||
await journalService.log(correlationId, 'wallet_tx_detected', {
|
await journalService.log(correlationId, 'wallet_tx_detected', {
|
||||||
walletAddress: observation.walletAddress,
|
walletAddress: observation.walletAddress,
|
||||||
signature: observation.signature,
|
signature: observation.signature,
|
||||||
tokenMint: observation.tokenMint,
|
tokenMint: observation.tokenMint!,
|
||||||
side: observation.side,
|
side: observation.side!,
|
||||||
amountSol: observation.amountSol,
|
amountSol: observation.amountSol || null,
|
||||||
tokenAmount: observation.tokenAmount,
|
tokenAmount: observation.tokenAmount || null,
|
||||||
detectedSlot: observation.slot,
|
detectedSlot: observation.slot,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,7 +265,7 @@ export class Application {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Retrieve quote for current price
|
// 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
|
// Normalize copy signal
|
||||||
const signal = normalizer.normalize(observation, currentQuote.priceSol, correlationId);
|
const signal = normalizer.normalize(observation, currentQuote.priceSol, correlationId);
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ export interface WalletTransactionObservation {
|
|||||||
signature: string;
|
signature: string;
|
||||||
slot: number;
|
slot: number;
|
||||||
walletAddress: string;
|
walletAddress: string;
|
||||||
tokenMint: string;
|
tokenMint?: string | null;
|
||||||
side: 'buy' | 'sell';
|
side?: 'buy' | 'sell' | null;
|
||||||
amountSol: number | null;
|
amountSol?: number | null;
|
||||||
tokenAmount: number | null;
|
tokenAmount?: number | null;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
|
correlationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWalletWatcher {
|
export interface IWalletWatcher {
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ export class SignalNormalizer implements ISignalNormalizer {
|
|||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
correlationId,
|
correlationId,
|
||||||
sourceWallet: observation.walletAddress,
|
sourceWallet: observation.walletAddress,
|
||||||
tokenMint: observation.tokenMint,
|
tokenMint: observation.tokenMint!,
|
||||||
side: observation.side,
|
side: observation.side!,
|
||||||
sourceTransactionSignature: observation.signature,
|
sourceTransactionSignature: observation.signature,
|
||||||
sourceTransactionTimestamp: observation.timestamp,
|
sourceTransactionTimestamp: observation.timestamp,
|
||||||
detectedTimestamp: new Date(),
|
detectedTimestamp: new Date(),
|
||||||
detectedSlot: observation.slot,
|
detectedSlot: observation.slot,
|
||||||
sourceAmountSol: observation.amountSol,
|
sourceAmountSol: observation.amountSol ?? null,
|
||||||
tokenAmount: observation.tokenAmount,
|
tokenAmount: observation.tokenAmount ?? null,
|
||||||
sourcePrice: sourcePriceSol,
|
sourcePrice: sourcePriceSol,
|
||||||
metadata: {
|
metadata: {
|
||||||
sourceSlot: observation.slot,
|
sourceSlot: observation.slot,
|
||||||
|
|||||||
42
src/solana/fetcher.ts
Normal file
42
src/solana/fetcher.ts
Normal file
@@ -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<VersionedTransactionResponse> {
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
@@ -1,6 +1,93 @@
|
|||||||
|
import { Connection, PublicKey } from '@solana/web3.js';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import { IWalletWatcher, WalletTransactionObservation } from '../domain/interfaces.js';
|
import { IWalletWatcher, WalletTransactionObservation } from '../domain/interfaces.js';
|
||||||
import { logger } from '../logging/index.js';
|
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[] = [];
|
||||||
|
private active = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private watchedWallets: string[],
|
||||||
|
private rpcEndpoint: string,
|
||||||
|
private wssEndpoint: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async startWatching(): Promise<void> {
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.subscriptionIds = [];
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
logger.info('Stopped RealWalletWatcher.');
|
||||||
|
}
|
||||||
|
|
||||||
|
onObservation(callback: (obs: WalletTransactionObservation) => Promise<void>): void {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class MockWalletWatcher implements IWalletWatcher {
|
export class MockWalletWatcher implements IWalletWatcher {
|
||||||
private callback: ((obs: WalletTransactionObservation) => Promise<void>) | null = null;
|
private callback: ((obs: WalletTransactionObservation) => Promise<void>) | null = null;
|
||||||
private intervalId: NodeJS.Timeout | null = null;
|
private intervalId: NodeJS.Timeout | null = null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ITradeRepository,
|
ITradeRepository,
|
||||||
ISimulationExecutionRepository,
|
ISimulationExecutionRepository,
|
||||||
IEventJournalRepository,
|
IEventJournalRepository,
|
||||||
|
IRawWalletTransactionRepository,
|
||||||
} from '../../src/domain/interfaces.js';
|
} from '../../src/domain/interfaces.js';
|
||||||
import {
|
import {
|
||||||
WatchedWallet,
|
WatchedWallet,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
Trade,
|
Trade,
|
||||||
SimulatedExecution,
|
SimulatedExecution,
|
||||||
EventJournalEntry,
|
EventJournalEntry,
|
||||||
|
RawWalletTransaction,
|
||||||
} from '../../src/domain/models.js';
|
} from '../../src/domain/models.js';
|
||||||
|
|
||||||
export class InMemoryWalletRepository implements IWalletRepository {
|
export class InMemoryWalletRepository implements IWalletRepository {
|
||||||
@@ -132,3 +134,18 @@ export class InMemoryStrategyRunsRepository {
|
|||||||
this.runs.push(run);
|
this.runs.push(run);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InMemoryRawWalletTransactionRepository implements IRawWalletTransactionRepository {
|
||||||
|
public transactions: RawWalletTransaction[] = [];
|
||||||
|
|
||||||
|
async saveTransaction(tx: RawWalletTransaction): Promise<void> {
|
||||||
|
const existing = this.transactions.find((t) => t.transactionSignature === tx.transactionSignature);
|
||||||
|
if (!existing) {
|
||||||
|
this.transactions.push(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactionBySignature(signature: string): Promise<RawWalletTransaction | null> {
|
||||||
|
return this.transactions.find((t) => t.transactionSignature === signature) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
294
tests/realMode.test.ts
Normal file
294
tests/realMode.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user