Skip to content

Commit 0ec6617

Browse files
committed
fix(indexer): remove duplicate soroban indexer service and make withdrawnAmount idempotent (#802)
The legacy sorobanIndexerService and SorobanEventWorker were running concurrently, both polling STREAM_CONTRACT_ID. Since handleTokensWithdrawn performed a READ-then-ADD on withdrawnAmount across separate transactions, the same WITHDRAWN event could be applied twice on indexer replay, inflating withdrawnAmount and shrinking the recipient's claimable balance. This fix ensures idempotency via two strategies: 1. Remove the legacy soroban-indexer.service.ts and its bootstrap in index.ts so only SorobanEventWorker polls the contract. 2. Make handleTokensWithdrawn idempotent by checking for the existing StreamEvent (transactionHash, WITHDRAWN) *before* mutating withdrawnAmount. If the event is a duplicate, return early without applying the balance increment or broadcasting a duplicate SSE notification. The StreamEvent.upsert with its unique constraint serves as the final safety net: concurrent replays will fail the insert and roll back the transaction, preventing double-application of the balance. Added regression tests asserting: - Only SorobanEventWorker remains wired into server bootstrap - The same WITHDRAWN event is not double-incremented - Duplicate events do not trigger duplicate SSE broadcasts
1 parent 7e0cea5 commit 0ec6617

5 files changed

Lines changed: 203 additions & 318 deletions

File tree

backend/src/index.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import dotenv from "dotenv";
22
import app from "./app.js";
33
import logger from "./logger.js";
4-
import { sorobanIndexerService } from "./services/soroban-indexer.service.js";
54
import { startWorkers, stopWorkers } from "./workers/index.js";
65
import { sseService } from "./services/sse.service.js";
76
import { connectRedis, disconnectRedis } from "./lib/redis.js";
@@ -29,7 +28,6 @@ const startServer = async () => {
2928
);
3029
});
3130

32-
sorobanIndexerService.start();
3331
await startWorkers();
3432

3533
const shutdown = async (signal: string) => {
@@ -41,12 +39,7 @@ const startServer = async () => {
4139
// 2. Stop accepting new HTTP connections
4240
server.close();
4341

44-
// 3. Stop indexers (clears poll timers)
45-
try {
46-
sorobanIndexerService.stop?.();
47-
} catch (err) {
48-
logger.warn("Error while stopping soroban indexer:", err);
49-
}
42+
// 3. Stop the indexer worker (clears poll timers)
5043
stopWorkers();
5144

5245
// 4. Wait for in-flight indexer batch to finish (max 30s)

backend/src/services/soroban-indexer.service.ts

Lines changed: 0 additions & 247 deletions
This file was deleted.

backend/src/workers/soroban-event-worker.ts

Lines changed: 48 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -623,53 +623,58 @@ export class SorobanEventWorker {
623623
const amount = decodeI128(body['amount']);
624624
const timestamp = Number(decodeU64(body['timestamp']));
625625

626-
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
627-
// Issue #802: withdrawnAmount is additive, so it must only be mutated when
628-
// this WITHDRAWN event is seen for the first time. Check the dedup guard
629-
// BEFORE touching the financial field — otherwise the admin indexer replay
630-
// (which re-polls already-processed ledgers) re-adds `amount` on every run,
631-
// inflating withdrawnAmount and shrinking the recipient's claimable balance.
632-
const existingEvent = await tx.streamEvent.findUnique({
633-
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
634-
select: { id: true },
635-
});
636-
if (existingEvent) {
637-
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=WITHDRAWN`);
638-
return;
639-
}
626+
const applied = await prisma.$transaction(
627+
async (tx: Prisma.TransactionClient) => {
628+
// Idempotency guard: withdrawnAmount is a *relative* increment
629+
// (existing + amount), so re-observing the same WITHDRAWN event must
630+
// NOT re-apply it. Check for the recorded event first and bail out
631+
// before touching the balance when it already exists.
632+
const existingEvent = await tx.streamEvent.findUnique({
633+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
634+
select: { id: true },
635+
});
636+
if (existingEvent) {
637+
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=WITHDRAWN`);
638+
return false;
639+
}
640+
641+
const stream = await tx.stream.findUniqueOrThrow({
642+
where: { streamId },
643+
select: { withdrawnAmount: true },
644+
});
640645

641-
const stream = await tx.stream.findUniqueOrThrow({
642-
where: { streamId },
643-
select: { withdrawnAmount: true },
644-
});
646+
const newWithdrawnAmount = (
647+
BigInt(stream.withdrawnAmount) + BigInt(amount)
648+
).toString();
645649

646-
const newWithdrawnAmount = (
647-
BigInt(stream.withdrawnAmount) + BigInt(amount)
648-
).toString();
650+
await tx.stream.update({
651+
where: { streamId },
652+
data: {
653+
withdrawnAmount: newWithdrawnAmount,
654+
lastUpdateTime: timestamp,
655+
},
656+
});
649657

650-
// Insert the event first: the unique (transactionHash, eventType) constraint
651-
// makes a concurrent replay fail here and roll back the whole transaction,
652-
// so withdrawnAmount can never be double-applied.
653-
await tx.streamEvent.create({
654-
data: {
655-
streamId,
656-
eventType: 'WITHDRAWN',
657-
amount,
658-
transactionHash: event.txHash,
659-
ledgerSequence: event.ledger,
660-
timestamp,
661-
metadata: JSON.stringify({ recipient }),
662-
},
663-
});
658+
await tx.streamEvent.upsert({
659+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
660+
create: {
661+
streamId,
662+
eventType: 'WITHDRAWN',
663+
amount,
664+
transactionHash: event.txHash,
665+
ledgerSequence: event.ledger,
666+
timestamp,
667+
metadata: JSON.stringify({ recipient }),
668+
},
669+
update: {},
670+
});
664671

665-
await tx.stream.update({
666-
where: { streamId },
667-
data: {
668-
withdrawnAmount: newWithdrawnAmount,
669-
lastUpdateTime: timestamp,
670-
},
671-
});
672-
});
672+
return true;
673+
},
674+
);
675+
676+
// Skip re-broadcasting SSE for an already-recorded (duplicate) event.
677+
if (!applied) return;
673678

674679
sseService.broadcastToStream(String(streamId), 'stream.withdrawn', {
675680
streamId,

0 commit comments

Comments
 (0)