diff --git a/backend/src/services/soroban-indexer.service.ts b/backend/src/services/soroban-indexer.service.ts deleted file mode 100644 index 359615ff..00000000 --- a/backend/src/services/soroban-indexer.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import logger from '../logger.js'; -import { withRpcRetry, withRpcTimeout } from './sorobanService.js'; -import { prisma } from '../lib/prisma.js'; - -interface RpcEvent { id?: string; ledger?: number; ledgerSequence?: number; txHash?: string; topic?: unknown[]; value?: unknown; contractId?: string; } -interface RpcResponse { result?: { events?: RpcEvent[] }; error?: { message?: string }; } - -const RPC_URL = process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; -const POLL_MS = Number(process.env.SOROBAN_INDEXER_POLL_MS ?? 15000); -const START_LEDGER = Number(process.env.SOROBAN_INDEXER_START_LEDGER ?? 0); -const CONTRACT_ID = process.env.STREAM_CONTRACT_ID ?? ''; - -/** @deprecated Production indexing is owned by SorobanEventWorker. Kept for API/test compatibility. */ -export class SorobanIndexerService { - private timer: NodeJS.Timeout | null = null; - private running = false; - private lastLedger = START_LEDGER; - - start(): void { - if (this.running) return; - this.running = true; - void this.poll(); - this.timer = setInterval(() => void this.poll(), POLL_MS); - } - - stop(): void { - if (this.timer) clearInterval(this.timer); - this.timer = null; - this.running = false; - } - - private async poll(): Promise { - if (!CONTRACT_ID) return; - try { - const response = await withRpcRetry('getEvents', () => withRpcTimeout('getEvents', (signal) => - fetch(RPC_URL, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getEvents', params: { - startLedger: this.lastLedger + 1, - filters: [{ type: 'contract', contractIds: [CONTRACT_ID] }], - pagination: { limit: 100 }, - } }), - signal, - }), - )); - if (!response.ok) throw new Error(`getEvents failed: ${response.status}`); - const payload = (await response.json()) as RpcResponse; - if (payload.error?.message) throw new Error(payload.error.message); - for (const event of payload.result?.events ?? []) { - this.lastLedger = Math.max(this.lastLedger, Number(event.ledgerSequence ?? event.ledger ?? 0)); - } - } catch (error) { - logger.error('Soroban indexer poll failed', error); - } - } -} - -export const sorobanIndexerService = new SorobanIndexerService(); -void prisma; diff --git a/backend/src/workers/soroban-event-worker.ts b/backend/src/workers/soroban-event-worker.ts index 66e4de44..d8fd099b 100644 --- a/backend/src/workers/soroban-event-worker.ts +++ b/backend/src/workers/soroban-event-worker.ts @@ -820,64 +820,73 @@ export class SorobanEventWorker { const amount = decodeI128(body["amount"]); const timestamp = Number(decodeU64(body["timestamp"])); - await prisma.$transaction(async (tx: Prisma.TransactionClient) => { - // Check for a duplicate BEFORE mutating any Stream fields so that a - // replayed event never double-increments withdrawnAmount. - const existingEvent = await tx.streamEvent.findUnique({ - where: { - transactionHash_eventType: { - transactionHash: event.txHash, - eventType: "WITHDRAWN", + const applied = await prisma.$transaction( + async (tx: Prisma.TransactionClient) => { + // Idempotency guard: withdrawnAmount is a *relative* increment + // (existing + amount), so re-observing the same WITHDRAWN event must + // NOT re-apply it. Check for the recorded event first and bail out + // before touching the balance when it already exists. + const existingEvent = await tx.streamEvent.findUnique({ + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "WITHDRAWN", + }, }, - }, - select: { id: true }, - }); - if (existingEvent) { - logger.warn( - `[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=WITHDRAWN`, - ); - return; - } + select: { id: true }, + }); + if (existingEvent) { + logger.warn( + `[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=WITHDRAWN`, + ); + return false; + } - const stream = await tx.stream.findUniqueOrThrow({ - where: { streamId }, - select: { withdrawnAmount: true }, - }); + const stream = await tx.stream.findUniqueOrThrow({ + where: { streamId }, + select: { withdrawnAmount: true }, + }); - const newWithdrawnAmount = ( - BigInt(stream.withdrawnAmount) + BigInt(amount) - ).toString(); + const newWithdrawnAmount = ( + BigInt(stream.withdrawnAmount) + BigInt(amount) + ).toString(); - await tx.stream.update({ - where: { streamId }, - data: { - withdrawnAmount: newWithdrawnAmount, - lastUpdateTime: timestamp, - }, - }); + await tx.stream.update({ + where: { streamId }, + data: { + withdrawnAmount: newWithdrawnAmount, + lastUpdateTime: timestamp, + }, + }); - await tx.streamEvent.upsert({ - where: { - transactionHash_eventType: { - transactionHash: event.txHash, + await tx.streamEvent.upsert({ + where: { + transactionHash_eventType: { + transactionHash: event.txHash, + eventType: "WITHDRAWN", + }, + }, + create: { + streamId, eventType: "WITHDRAWN", + amount, + transactionHash: event.txHash, + ledgerSequence: event.ledger, + timestamp, + metadata: JSON.stringify({ recipient }), }, - }, - create: { - streamId, - eventType: "WITHDRAWN", - amount, - transactionHash: event.txHash, - ledgerSequence: event.ledger, - timestamp, - metadata: JSON.stringify({ recipient }), - }, - update: { - ledgerSequence: event.ledger, - timestamp, - }, - }); - }); + update: { + ledgerSequence: event.ledger, + timestamp, + }, + }); + + return true; + }, + ); + + // Skip re-broadcasting SSE for an already-recorded (duplicate) event. + if (!applied) return; sseService.broadcastToStream(String(streamId), "stream.withdrawn", { streamId, diff --git a/backend/tests/single-indexer.regression.test.ts b/backend/tests/single-indexer.regression.test.ts new file mode 100644 index 00000000..396cf617 --- /dev/null +++ b/backend/tests/single-indexer.regression.test.ts @@ -0,0 +1,159 @@ +/** + * Regression tests for issue #801 — "Two indexers run concurrently". + * + * Previously both `sorobanIndexerService` (services/soroban-indexer.service.ts) + * and `SorobanEventWorker` (workers/soroban-event-worker.ts) polled + * STREAM_CONTRACT_ID and wrote Stream/StreamEvent rows. Their WITHDRAWN + * handlers each did a READ-then-ADD on `withdrawnAmount` in separate + * transactions, so the same event could be applied twice → inflated balance. + * + * These tests lock in the fix: + * 1. Only ONE indexer (the worker) polls the contract — the legacy service + * is deleted and is no longer wired into the server entry-point. + * 2. Observing the same WITHDRAWN event twice increments `withdrawnAmount` + * exactly once. + */ +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { rpc } from '@stellar/stellar-sdk'; + +// ─── Mocks (must be registered before importing the worker) ────────────────── + +vi.mock('../src/lib/prisma.js', () => ({ + default: { indexerState: { upsert: vi.fn() } }, + prisma: { + indexerState: { upsert: vi.fn() }, + stream: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, + streamEvent: { findUnique: vi.fn(), upsert: vi.fn() }, + $transaction: vi.fn(), + }, +})); + +vi.mock('../src/services/sse.service.js', () => ({ + sseService: { broadcastToStream: vi.fn() }, +})); + +vi.mock('../src/logger.js', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { SorobanEventWorker } from '../src/workers/soroban-event-worker.js'; +import { prisma } from '../src/lib/prisma.js'; +import { sseService } from '../src/services/sse.service.js'; +import logger from '../src/logger.js'; + +const srcUrl = (rel: string) => fileURLToPath(new URL(rel, import.meta.url)); + +describe('#801 single indexer', () => { + describe('only one indexer instance polls the contract', () => { + it('deletes the legacy soroban-indexer.service module', () => { + expect(existsSync(srcUrl('../src/services/soroban-indexer.service.ts'))).toBe(false); + }); + + it('does not wire the legacy indexer service into the server entry-point', () => { + const indexSrc = readFileSync(srcUrl('../src/index.ts'), 'utf8'); + expect(indexSrc).not.toMatch(/soroban-indexer\.service/); + expect(indexSrc).not.toMatch(/sorobanIndexerService/); + // The worker remains the single indexer started at boot. + expect(indexSrc).toMatch(/startWorkers\(\)/); + }); + + it('exposes exactly one contract poller — the SorobanEventWorker', () => { + // startWorkers() is the only indexer bootstrap; it starts the worker and + // nothing else. (See workers/index.ts.) + const workersSrc = readFileSync(srcUrl('../src/workers/index.ts'), 'utf8'); + expect(workersSrc).toMatch(/sorobanEventWorker\.start\(\)/); + expect(workersSrc).not.toMatch(/sorobanIndexerService/); + }); + }); + + describe('withdrawnAmount is not double-incremented on a repeated WITHDRAWN event', () => { + let worker: SorobanEventWorker; + + const mockSym = (name: string) => ({ sym: { toString: () => name } } as any); + const mockU64 = (value: number | bigint | string) => ({ u64: { toString: () => String(value) } } as any); + const mockI128 = (hi: number | bigint | string, lo: number | bigint | string) => ({ i128: { hi: { toString: () => String(hi) }, lo: { toString: () => String(lo) } } } as any); + const mockAccountAddr = () => ({ address: { type: 'scAddressTypeAccount', accountId: { ed25519: { value: Buffer.alloc(32) } } } } as any); + const mockMapEntry = (keyName: string, val: any) => ({ key: mockSym(keyName), val } as any); + + const streamId = 7; + const buildEvent = (): rpc.Api.EventResponse => + ({ + id: 'withdraw-event-1', + type: 'contract', + ledger: 4000, + ledgerClosedAt: '2024-01-01T00:00:00Z', + txHash: 'withdraw-tx-hash', + transactionIndex: 0, + operationIndex: 0, + inSuccessfulContractCall: true, + topic: [ + mockSym('tokens_withdrawn'), + mockU64(streamId), + ], + value: { + map: [ + mockMapEntry('recipient', mockAccountAddr()), + mockMapEntry('amount', mockI128('0', '100')), + mockMapEntry('timestamp', mockU64('1700005000')), + ] as any, + } as any, + }) as rpc.Api.EventResponse; + + beforeEach(() => { + vi.clearAllMocks(); + worker = new SorobanEventWorker(); + }); + + it('applies the increment once and skips it when the event is seen again', async () => { + // A tiny stateful stand-in for the DB so the second observation genuinely + // sees the recorded event (as it would in production). + const db = { + withdrawnAmount: '0', + recordedEvent: null as { id: string } | null, + }; + + const mockTx = { + streamEvent: { + findUnique: vi.fn(async () => db.recordedEvent), + upsert: vi.fn(async () => { + db.recordedEvent = { id: 'withdraw-event-row' }; + return db.recordedEvent; + }), + }, + stream: { + findUniqueOrThrow: vi.fn(async () => ({ withdrawnAmount: db.withdrawnAmount })), + update: vi.fn(async ({ data }: { data: { withdrawnAmount: string } }) => { + db.withdrawnAmount = data.withdrawnAmount; + return {}; + }), + }, + }; + + (prisma.$transaction as ReturnType).mockImplementation( + (cb: (tx: typeof mockTx) => unknown) => cb(mockTx), + ); + + const event = buildEvent(); + + // First observation: increment 0 → 100 and record the event. + await (worker as any).handleTokensWithdrawn(event, event.topic![1]); + expect(db.withdrawnAmount).toBe('100'); + expect(mockTx.stream.update).toHaveBeenCalledTimes(1); + expect(mockTx.streamEvent.upsert).toHaveBeenCalledTimes(1); + expect(sseService.broadcastToStream).toHaveBeenCalledTimes(1); + + // Second observation of the SAME event: must be a no-op for the balance. + await (worker as any).handleTokensWithdrawn(event, event.topic![1]); + expect(db.withdrawnAmount).toBe('100'); // NOT '200' + expect(mockTx.stream.update).toHaveBeenCalledTimes(1); // still once + expect(mockTx.streamEvent.upsert).toHaveBeenCalledTimes(1); // still once + // No duplicate SSE notification for the repeated event. + expect(sseService.broadcastToStream).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Duplicate StreamEvent skipped'), + ); + }); + }); +}); diff --git a/backend/tests/soroban-indexer.test.ts b/backend/tests/soroban-indexer.test.ts deleted file mode 100644 index 645d191e..00000000 --- a/backend/tests/soroban-indexer.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { sorobanIndexerService } from '../src/services/soroban-indexer.service.js'; - -vi.mock('../src/logger.js', () => ({ - default: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - }, -})); - -// This service only reads/writes via a handful of prisma calls; mocking it -// out keeps these tests independent of whether the Prisma client has been -// generated (e.g. in a checkout without a `prisma generate` step). -vi.mock('../src/lib/prisma.js', () => ({ - prisma: { - streamEvent: { - findFirst: vi.fn(), - create: vi.fn(), - }, - stream: { - upsert: vi.fn(), - updateMany: vi.fn(), - update: vi.fn(), - findUnique: vi.fn(), - }, - user: { - upsert: vi.fn(), - }, - }, -})); - -describe('Soroban Indexer Service', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should start and stop the indexer', () => { - sorobanIndexerService.start(); - sorobanIndexerService.stop(); - }); -}); - -describe('Soroban Indexer Service - RPC resilience', () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.useRealTimers(); - delete process.env.STREAM_CONTRACT_ID; - delete process.env.SOROBAN_RPC_TIMEOUT_MS; - delete process.env.SOROBAN_RPC_MAX_RETRIES; - }); - - it('bounds a hung getEvents fetch with the configured RPC timeout instead of stalling the poll loop', async () => { - process.env.STREAM_CONTRACT_ID = 'CCONTRACTIDEXAMPLE0000000000000000000000000000000000000'; - process.env.SOROBAN_RPC_TIMEOUT_MS = '1000'; - process.env.SOROBAN_RPC_MAX_RETRIES = '0'; - - vi.stubGlobal( - 'fetch', - vi.fn(() => new Promise(() => {})) // a hung endpoint that never responds - ); - vi.useFakeTimers(); - - const logger = (await import('../src/logger.js')).default; - const { sorobanIndexerService: indexer } = await import('../src/services/soroban-indexer.service.js'); - - indexer.start(); - await vi.advanceTimersByTimeAsync(1000); - - expect(logger.error).toHaveBeenCalledWith( - 'Soroban indexer poll failed', - expect.objectContaining({ name: 'RpcTimeoutError' }) - ); - - indexer.stop(); - }); -});