Add real Solana wallet observation and raw transaction persistence
This commit is contained in:
@@ -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<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