934 lines
33 KiB
TypeScript
934 lines
33 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
|
|
// Mock database interactions to avoid live connections
|
|
vi.mock('../src/persistence/db.js', () => {
|
|
return {
|
|
initDb: vi.fn(),
|
|
verifyDbHealth: vi.fn().mockResolvedValue(undefined),
|
|
closeDb: vi.fn().mockResolvedValue(undefined),
|
|
getDbPool: vi.fn().mockImplementation(() => {
|
|
return {
|
|
query: vi.fn().mockResolvedValue({
|
|
rows: [{ id: 'mock-id', address: '3yF9asA9B7G3Y1G7as78gHJKa7A', name: 'Mock Wallet', is_active: true, created_at: new Date(), updated_at: new Date() }],
|
|
rowCount: 1,
|
|
}),
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('BoundedTransactionQueue Unit Tests', () => {
|
|
let mockMetrics: any;
|
|
|
|
beforeEach(() => {
|
|
mockMetrics = {
|
|
recordQueueDepth: vi.fn(),
|
|
recordQueueRetries: vi.fn(),
|
|
recordQueue429s: vi.fn(),
|
|
recordQuoteLatency: vi.fn(),
|
|
recordSignalProcessingLatency: vi.fn(),
|
|
recordWalletEventReceived: vi.fn(),
|
|
recordWalletQueueDepth: vi.fn(),
|
|
recordOldestQueuedAge: vi.fn(),
|
|
recordDroppedOldestCount: vi.fn(),
|
|
recordTransactionRelevance: vi.fn(),
|
|
recordRpcRequestRate: vi.fn(),
|
|
recordQueueProcessed: vi.fn(),
|
|
recordRpcFetchError: vi.fn(),
|
|
};
|
|
});
|
|
|
|
it('should respect max concurrency limit and rate limiting delay', async () => {
|
|
const { BoundedTransactionQueue } = await import('../src/solana/fetcher.js');
|
|
const mockTx = { slot: 100, blockTime: 123456 };
|
|
connection.getTransaction.mockResolvedValue(mockTx);
|
|
|
|
const queue = new BoundedTransactionQueue(
|
|
connection as any,
|
|
2, // maxConcurrency = 2
|
|
100, // minDelayMs = 100ms
|
|
10, // maxQueueSize = 10
|
|
mockMetrics,
|
|
);
|
|
queue.start();
|
|
|
|
const successSigs: string[] = [];
|
|
const pushItem = (sig: string) => {
|
|
return new Promise<void>((resolve) => {
|
|
queue.push({
|
|
signature: sig,
|
|
walletAddress: 'wallet1',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr',
|
|
onSuccess: async () => {
|
|
successSigs.push(sig);
|
|
resolve();
|
|
},
|
|
onFailure: async () => {
|
|
resolve();
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
// Push 3 items concurrently
|
|
const p1 = pushItem('sig1');
|
|
const p2 = pushItem('sig2');
|
|
const p3 = pushItem('sig3');
|
|
|
|
// Check that at most 2 are active initially
|
|
expect(queue.getActiveCount()).toBeLessThanOrEqual(2);
|
|
|
|
await Promise.all([p1, p2, p3]);
|
|
|
|
expect(successSigs).toContain('sig1');
|
|
expect(successSigs).toContain('sig2');
|
|
expect(successSigs).toContain('sig3');
|
|
expect(queue.getDepth()).toBe(0);
|
|
expect(queue.getActiveCount()).toBe(0);
|
|
|
|
queue.stop();
|
|
});
|
|
|
|
it('should drop incoming items and apply backpressure when queue size is exceeded', async () => {
|
|
const { BoundedTransactionQueue } = await import('../src/solana/fetcher.js');
|
|
const mockTx = { slot: 100, blockTime: 123456 };
|
|
connection.getTransaction.mockResolvedValue(mockTx);
|
|
|
|
const queue = new BoundedTransactionQueue(
|
|
connection as any,
|
|
1, // maxConcurrency = 1
|
|
100, // minDelay = 100
|
|
2, // maxQueueSize = 2 (allows 2 items to wait in queue)
|
|
);
|
|
queue.start();
|
|
|
|
const results: string[] = [];
|
|
const errors: string[] = [];
|
|
|
|
const pushItem = (sig: string) => {
|
|
queue.push({
|
|
signature: sig,
|
|
walletAddress: 'wallet1',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr',
|
|
onSuccess: async () => {
|
|
results.push(sig);
|
|
},
|
|
onFailure: async (err) => {
|
|
errors.push(`${sig}:${err.message}`);
|
|
},
|
|
});
|
|
};
|
|
|
|
// Push 4 items (concurrency=1, max queue=2 -> 1 active, 2 in queue, 4th item triggers drop-oldest of item2)
|
|
pushItem('item1'); // goes active immediately (active=1, queue=[])
|
|
pushItem('item2'); // queued (active=1, queue=[item2])
|
|
pushItem('item3'); // queued (active=1, queue=[item2, item3])
|
|
pushItem('item4'); // queue full! item2 is dropped, queue becomes [item3, item4]
|
|
|
|
expect(errors).toContain('item2:Dropped from queue due to backpressure');
|
|
|
|
queue.stop();
|
|
});
|
|
|
|
it('should implement HTTP 429 exponential backoff with jitter and successfully retry', async () => {
|
|
const mockTx = { slot: 100, blockTime: 123456 };
|
|
// First attempt triggers HTTP 429 rate limit error
|
|
const rateLimitErr = { status: 429, message: 'Too Many Requests' };
|
|
connection.getTransaction
|
|
.mockRejectedValueOnce(rateLimitErr)
|
|
.mockResolvedValueOnce(mockTx);
|
|
|
|
const result = await fetchTransactionWithRetry(connection as any, 'sig-429', 3, 10, 50, mockMetrics);
|
|
expect(result).toEqual(mockTx);
|
|
expect(connection.getTransaction).toHaveBeenCalledTimes(2);
|
|
expect(mockMetrics.recordQueue429s).toHaveBeenCalledWith('sig-429');
|
|
});
|
|
|
|
it('should gracefully drain or terminate the queue when stopped during graceful shutdown', async () => {
|
|
const { BoundedTransactionQueue } = await import('../src/solana/fetcher.js');
|
|
const queue = new BoundedTransactionQueue(
|
|
connection as any,
|
|
1,
|
|
100,
|
|
10,
|
|
);
|
|
queue.start();
|
|
|
|
let failed = false;
|
|
queue.push({
|
|
signature: 'sig-drain',
|
|
walletAddress: 'wallet',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr',
|
|
onSuccess: async () => {},
|
|
onFailure: async () => {
|
|
failed = true;
|
|
},
|
|
});
|
|
|
|
expect(queue.getDepth()).toBe(0); // goes to active immediately
|
|
queue.stop();
|
|
|
|
// Pushing after stop must fail immediately
|
|
const pushedAfter = queue.push({
|
|
signature: 'sig-after',
|
|
walletAddress: 'wallet',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr',
|
|
onSuccess: async () => {},
|
|
onFailure: async () => {
|
|
failed = true;
|
|
},
|
|
});
|
|
|
|
expect(pushedAfter).toBe(false);
|
|
expect(failed).toBe(true);
|
|
});
|
|
|
|
it('should correctly schedule high-volume wallet without starving another wallet (fair round robin)', async () => {
|
|
const { BoundedTransactionQueue } = await import('../src/solana/fetcher.js');
|
|
const mockTx = { slot: 100, blockTime: 123456 };
|
|
connection.getTransaction.mockResolvedValue(mockTx);
|
|
|
|
const queue = new BoundedTransactionQueue(
|
|
connection as any,
|
|
1, // maxConcurrency = 1
|
|
10, // minDelay = 10ms
|
|
10, // max size per-wallet
|
|
);
|
|
queue.start();
|
|
|
|
const successOrder: string[] = [];
|
|
|
|
const pushItem = (sig: string, wallet: string) => {
|
|
queue.push({
|
|
signature: sig,
|
|
walletAddress: wallet,
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr',
|
|
onSuccess: async () => {
|
|
successOrder.push(sig);
|
|
},
|
|
onFailure: async () => {},
|
|
});
|
|
};
|
|
|
|
// Push A1. It executes immediately.
|
|
pushItem('A1', 'walletA'); // activeCount=1, walletOrder=[]
|
|
|
|
// Now enqueue additional tasks.
|
|
// A2 is pushed. It goes to 'walletA' queue. walletOrder becomes ['walletA']
|
|
pushItem('A2', 'walletA');
|
|
// A3 is pushed. It goes to 'walletA' queue. Since 'walletA' is already in walletOrder, order remains ['walletA']
|
|
pushItem('A3', 'walletA');
|
|
// B1 is pushed. It goes to 'walletB' queue. walletOrder becomes ['walletA', 'walletB']
|
|
pushItem('B1', 'walletB');
|
|
|
|
// Now A1 finishes. processNext() is called.
|
|
// It retrieves from scheduling order: 'walletOrder.shift()' -> yields 'walletA'!
|
|
// So 'A2' is dequeued and executed next.
|
|
// Since 'walletA' still has 'A3', it is re-queued at the back of the order.
|
|
// walletOrder becomes: ['walletB', 'walletA']
|
|
//
|
|
// Then A2 finishes. processNext() is called.
|
|
// It shifts 'walletOrder' -> yields 'walletB'!
|
|
// So 'B1' is dequeued and executed third.
|
|
//
|
|
// Finally B1 finishes. processNext() shifts -> yields 'walletA' -> executes 'A3'.
|
|
//
|
|
// Hence successOrder will be: ['A1', 'A2', 'B1', 'A3']
|
|
// This proves B1 is scheduled fairly and is NOT starved by A3 even though A was queued first!
|
|
|
|
// Process and wait for all tasks to settle
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
expect(successOrder[0]).toBe('A1');
|
|
expect(successOrder[1]).toBe('A2');
|
|
expect(successOrder[2]).toBe('B1'); // Fair scheduling ensures B1 executes third, before A3!
|
|
expect(successOrder[3]).toBe('A3');
|
|
|
|
queue.stop();
|
|
});
|
|
|
|
it('should drop oldest items and preserve newest events under backpressure', async () => {
|
|
const { BoundedTransactionQueue } = await import('../src/solana/fetcher.js');
|
|
const mockTx = { slot: 100, blockTime: 123456 };
|
|
connection.getTransaction.mockResolvedValue(mockTx);
|
|
|
|
const queue = new BoundedTransactionQueue(
|
|
connection as any,
|
|
1, // maxConcurrency = 1
|
|
50, // minDelay = 50ms
|
|
2, // max queue size per-wallet = 2
|
|
);
|
|
queue.start();
|
|
|
|
const results: string[] = [];
|
|
const errors: string[] = [];
|
|
|
|
const pushItem = (sig: string) => {
|
|
queue.push({
|
|
signature: sig,
|
|
walletAddress: 'wallet1',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr',
|
|
onSuccess: async () => {
|
|
results.push(sig);
|
|
},
|
|
onFailure: async (err) => {
|
|
errors.push(`${sig}:${err.message}`);
|
|
},
|
|
});
|
|
};
|
|
|
|
pushItem('T1'); // Runs immediately
|
|
pushItem('T2'); // Queued (pos 0)
|
|
pushItem('T3'); // Queued (pos 1)
|
|
pushItem('T4'); // Queued full! Drops oldest ('T2'), appends T4. Queue is now: [T3, T4]
|
|
|
|
expect(errors).toContain('T2:Dropped from queue due to backpressure');
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
expect(results).toContain('T1');
|
|
expect(results).not.toContain('T2'); // dropped
|
|
expect(results).toContain('T3');
|
|
expect(results).toContain('T4'); // newest preserved!
|
|
|
|
queue.stop();
|
|
});
|
|
|
|
it('should prove backpressure drop is not reported as fetch failure, generates no stack trace, and metrics distinguish it', async () => {
|
|
const { BoundedTransactionQueue, BackpressureDropError } = await import('../src/solana/fetcher.js');
|
|
const { logger } = await import('../src/logging/index.js');
|
|
const loggerWarnSpy = vi.spyOn(logger, 'warn');
|
|
const loggerErrorSpy = vi.spyOn(logger, 'error');
|
|
|
|
const queue = new BoundedTransactionQueue(
|
|
connection as any,
|
|
1, // maxConcurrency = 1
|
|
10, // minDelay = 10ms
|
|
1, // max queue size per-wallet = 1
|
|
mockMetrics,
|
|
);
|
|
queue.start();
|
|
|
|
let capturedError: any = null;
|
|
|
|
queue.push({
|
|
signature: 'T1',
|
|
walletAddress: 'wallet1',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr1',
|
|
onSuccess: async () => {},
|
|
onFailure: async () => {},
|
|
});
|
|
|
|
queue.push({
|
|
signature: 'T2',
|
|
walletAddress: 'wallet1',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr2',
|
|
onSuccess: async () => {},
|
|
onFailure: async (err) => {
|
|
capturedError = err;
|
|
},
|
|
});
|
|
|
|
// T3 will trigger drop of T2
|
|
queue.push({
|
|
signature: 'T3',
|
|
walletAddress: 'wallet1',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr3',
|
|
onSuccess: async () => {},
|
|
onFailure: async () => {},
|
|
});
|
|
|
|
expect(capturedError).toBeInstanceOf(BackpressureDropError);
|
|
expect(capturedError.stack).toBeUndefined();
|
|
|
|
// Verify that backpressure drop logs WARN, not ERROR
|
|
const backpressureWarns = loggerWarnSpy.mock.calls.filter(call =>
|
|
call[0] && call[0].includes('BACKPRESSURE')
|
|
);
|
|
expect(backpressureWarns.length).toBeGreaterThan(0);
|
|
|
|
const backpressureErrors = loggerErrorSpy.mock.calls.filter(call =>
|
|
call[0] && call[0].includes('BACKPRESSURE')
|
|
);
|
|
expect(backpressureErrors.length).toBe(0);
|
|
|
|
// Verify operational metrics: recordDroppedOldestCount called, recordRpcFetchError NOT called
|
|
expect(mockMetrics.recordDroppedOldestCount).toHaveBeenCalledWith('wallet1');
|
|
expect(mockMetrics.recordRpcFetchError).not.toHaveBeenCalled();
|
|
|
|
queue.stop();
|
|
loggerWarnSpy.mockRestore();
|
|
loggerErrorSpy.mockRestore();
|
|
});
|
|
|
|
it('should prove one rate-limit wait produces only one scheduling/log event and does not spam logs', async () => {
|
|
const { BoundedTransactionQueue } = await import('../src/solana/fetcher.js');
|
|
const { logger } = await import('../src/logging/index.js');
|
|
const loggerDebugSpy = vi.spyOn(logger, 'debug');
|
|
|
|
const queue = new BoundedTransactionQueue(
|
|
connection as any,
|
|
1, // maxConcurrency = 1
|
|
1000, // minDelay = 1000ms (so we trigger rate limit)
|
|
10, // max queue size per-wallet = 10
|
|
mockMetrics,
|
|
);
|
|
queue.start();
|
|
|
|
// Push item 1 (executed immediately, updates lastRequestTime)
|
|
queue.push({
|
|
signature: 'R1',
|
|
walletAddress: 'wallet1',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: 'corr1',
|
|
onSuccess: async () => {},
|
|
onFailure: async () => {},
|
|
});
|
|
|
|
// Push 5 more items concurrently (within rate-limit interval)
|
|
for (let i = 2; i <= 6; i++) {
|
|
queue.push({
|
|
signature: `R${i}`,
|
|
walletAddress: 'wallet1',
|
|
slot: 100,
|
|
detectedAt: new Date(),
|
|
correlationId: `corr${i}`,
|
|
onSuccess: async () => {},
|
|
onFailure: async () => {},
|
|
});
|
|
}
|
|
|
|
// Wait for R1 to finish and trigger the rate limit wait
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
// Check the debug logs for "Global rate limit: waiting"
|
|
const rateLimitDebugs = loggerDebugSpy.mock.calls.filter(call =>
|
|
call[0] && call[0].includes('Global rate limit: waiting')
|
|
);
|
|
|
|
// Even with 5 pushes under rate limit, we should only have ONE rate limit waiting log event
|
|
expect(rateLimitDebugs.length).toBe(1);
|
|
|
|
queue.stop();
|
|
loggerDebugSpy.mockRestore();
|
|
});
|
|
|
|
it('should classify fetched transactions correctly based on SOL/SPL token balance changes', async () => {
|
|
const { isTransactionRelevant } = await import('../src/solana/parser.js');
|
|
|
|
// 1. Irrelevant payload (no balance changes or only extremely tiny SOL shift simulating fee)
|
|
const irrelevantTx: any = {
|
|
transaction: {
|
|
message: {
|
|
getAccountKeys: () => ({
|
|
staticAccountKeys: [{ toBase58: () => 'my-wallet' }],
|
|
}),
|
|
},
|
|
},
|
|
meta: {
|
|
preBalances: [1000000000n],
|
|
postBalances: [1000000000n], // exactly 0 change
|
|
preTokenBalances: [],
|
|
postTokenBalances: [],
|
|
},
|
|
};
|
|
|
|
// 2. Meaningful SOL transfer payload
|
|
const relevantSolTx: any = {
|
|
transaction: {
|
|
message: {
|
|
getAccountKeys: () => ({
|
|
staticAccountKeys: [{ toBase58: () => 'my-wallet' }],
|
|
}),
|
|
},
|
|
},
|
|
meta: {
|
|
preBalances: [1000000000],
|
|
postBalances: [900000000], // -100,000,000 lamports (-0.1 SOL) change
|
|
preTokenBalances: [],
|
|
postTokenBalances: [],
|
|
},
|
|
};
|
|
|
|
// 3. Meaningful SPL Token swap payload
|
|
const relevantTokenTx: any = {
|
|
transaction: {
|
|
message: {
|
|
getAccountKeys: () => ({
|
|
staticAccountKeys: [{ toBase58: () => 'my-wallet' }],
|
|
}),
|
|
},
|
|
},
|
|
meta: {
|
|
preBalances: [1000000000],
|
|
postBalances: [1000000000], // 0 SOL change
|
|
preTokenBalances: [
|
|
{ owner: 'my-wallet', mint: 'token-mint-1', uiTokenAmount: { amount: '1000' } },
|
|
],
|
|
postTokenBalances: [
|
|
{ owner: 'my-wallet', mint: 'token-mint-1', uiTokenAmount: { amount: '5000' } }, // +4000 change
|
|
],
|
|
},
|
|
};
|
|
|
|
expect(isTransactionRelevant(irrelevantTx, 'my-wallet')).toBe(false);
|
|
expect(isTransactionRelevant(relevantSolTx, 'my-wallet')).toBe(true);
|
|
expect(isTransactionRelevant(relevantTokenTx, 'my-wallet')).toBe(true);
|
|
});
|
|
|
|
it('should correctly resolve accounts and relevance for legacy transactions', async () => {
|
|
const { isTransactionRelevant } = await import('../src/solana/parser.js');
|
|
|
|
const legacyTx: any = {
|
|
transaction: {
|
|
message: {
|
|
accountKeys: [{ toBase58: () => 'my-wallet' }, { toBase58: () => 'other-acct' }],
|
|
},
|
|
},
|
|
meta: {
|
|
preBalances: [2000000000, 50000000],
|
|
postBalances: [1000000000, 105000000], // SOL shift: -1 SOL for my-wallet (relevant)
|
|
preTokenBalances: [],
|
|
postTokenBalances: [],
|
|
},
|
|
};
|
|
|
|
expect(isTransactionRelevant(legacyTx, 'my-wallet')).toBe(true);
|
|
expect(isTransactionRelevant(legacyTx, 'other-acct')).toBe(true);
|
|
});
|
|
|
|
it('should correctly resolve accounts and relevance for v0 transactions without address lookup tables', async () => {
|
|
const { isTransactionRelevant } = await import('../src/solana/parser.js');
|
|
|
|
const v0TxWithoutAlts: any = {
|
|
transaction: {
|
|
message: {
|
|
staticAccountKeys: [{ toBase58: () => 'my-wallet' }, { toBase58: () => 'other-acct' }],
|
|
},
|
|
},
|
|
meta: {
|
|
preBalances: [1000000000, 50000000],
|
|
postBalances: [1000000000, 50000000], // no SOL shift
|
|
preTokenBalances: [
|
|
{ owner: 'my-wallet', mint: 'token-mint-v0', uiTokenAmount: { amount: '10' } }
|
|
],
|
|
postTokenBalances: [
|
|
{ owner: 'my-wallet', mint: 'token-mint-v0', uiTokenAmount: { amount: '110' } } // relevant SPL shift
|
|
],
|
|
},
|
|
};
|
|
|
|
expect(isTransactionRelevant(v0TxWithoutAlts, 'my-wallet')).toBe(true);
|
|
});
|
|
|
|
it('should correctly resolve accounts and relevance for v0 transactions with loaded addresses (ALTs)', async () => {
|
|
const { isTransactionRelevant, getAccountKeysArray } = await import('../src/solana/parser.js');
|
|
|
|
const v0TxWithAlts: any = {
|
|
transaction: {
|
|
message: {
|
|
staticAccountKeys: [{ toBase58: () => 'static-1' }],
|
|
},
|
|
},
|
|
meta: {
|
|
loadedAddresses: {
|
|
writable: [{ toBase58: () => 'loaded-writable-1' }],
|
|
readonly: [{ toBase58: () => 'loaded-readonly-1' }, { toBase58: () => 'loaded-readonly-2' }]
|
|
},
|
|
preBalances: [5000000000, 1000000000, 50000000, 50000000],
|
|
postBalances: [4900000000, 1100000000, 50000000, 50000000], // transfer 0.1 SOL from static-1 to loaded-writable-1
|
|
preTokenBalances: [],
|
|
postTokenBalances: [],
|
|
},
|
|
};
|
|
|
|
// Verify full index mapping:
|
|
// 0 -> static-1, 1 -> loaded-writable-1, 2 -> loaded-readonly-1, 3 -> loaded-readonly-2
|
|
const keys = getAccountKeysArray(v0TxWithAlts).map(k => k.toBase58());
|
|
expect(keys).toEqual(['static-1', 'loaded-writable-1', 'loaded-readonly-1', 'loaded-readonly-2']);
|
|
|
|
// static-1 had post-pre = 4.9B - 5B = -100M lamports (relevant SOL change)
|
|
expect(isTransactionRelevant(v0TxWithAlts, 'static-1')).toBe(true);
|
|
|
|
// loaded-writable-1 had post-pre = 1.1B - 1B = 100M lamports (relevant SOL change)
|
|
expect(isTransactionRelevant(v0TxWithAlts, 'loaded-writable-1')).toBe(true);
|
|
|
|
// loaded-readonly-1 had post-pre = 50M - 50M = 0 lamports (irrelevant)
|
|
expect(isTransactionRelevant(v0TxWithAlts, 'loaded-readonly-1')).toBe(false);
|
|
});
|
|
|
|
it('should throw an error on account resolution or parsing failures as a distinct error state', async () => {
|
|
const { isTransactionRelevant } = await import('../src/solana/parser.js');
|
|
|
|
const malformedTx: any = {
|
|
transaction: {
|
|
// message is missing
|
|
},
|
|
};
|
|
|
|
await expect(async () => {
|
|
isTransactionRelevant(malformedTx, 'any-wallet');
|
|
}).rejects.toThrow('Transaction message is missing or unresolvable');
|
|
});
|
|
});
|
|
|
|
describe('Application Real Mode vs Mock Mode Loop Behavior', () => {
|
|
it('should NOT start SimulationScheduler processing loop in real mode and keep pending simulated executions untouched', async () => {
|
|
const oldMode = process.env.WALLET_WATCHER_MODE;
|
|
process.env.WALLET_WATCHER_MODE = 'real';
|
|
process.env.WATCHED_WALLETS = '3yF9asA9B7G3Y1G7as78gHJKa7A';
|
|
|
|
try {
|
|
const { Application } = await import('../src/application/index.js');
|
|
const app = new Application();
|
|
await app.bootstrap();
|
|
|
|
// Proves that no interval id was created/started for the simulated loop
|
|
expect(app.simulationIntervalId).toBeNull();
|
|
|
|
await app.shutdown('SIGINT');
|
|
} finally {
|
|
if (oldMode) {
|
|
process.env.WALLET_WATCHER_MODE = oldMode;
|
|
} else {
|
|
delete process.env.WALLET_WATCHER_MODE;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should start SimulationScheduler processing loop in mock mode', async () => {
|
|
const oldMode = process.env.WALLET_WATCHER_MODE;
|
|
process.env.WALLET_WATCHER_MODE = 'mock';
|
|
process.env.WATCHED_WALLETS = '3yF9asA9B7G3Y1G7as78gHJKa7A';
|
|
|
|
try {
|
|
const { Application } = await import('../src/application/index.js');
|
|
const app = new Application();
|
|
await app.bootstrap();
|
|
|
|
// Proves that interval id WAS started for the simulated loop
|
|
expect(app.simulationIntervalId).not.toBeNull();
|
|
|
|
await app.shutdown('SIGINT');
|
|
} finally {
|
|
if (oldMode) {
|
|
process.env.WALLET_WATCHER_MODE = oldMode;
|
|
} else {
|
|
delete process.env.WALLET_WATCHER_MODE;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|