Skip to content

Commit 44abe87

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 254b057 commit 44abe87

5 files changed

Lines changed: 203 additions & 314 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 & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -626,49 +626,58 @@ export class SorobanEventWorker {
626626
const amount = decodeI128(body['amount']);
627627
const timestamp = Number(decodeU64(body['timestamp']));
628628

629-
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
630-
// Check for a duplicate BEFORE mutating any Stream fields so that a
631-
// replayed event never double-increments withdrawnAmount.
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-
}
629+
const applied = await prisma.$transaction(
630+
async (tx: Prisma.TransactionClient) => {
631+
// Idempotency guard: withdrawnAmount is a *relative* increment
632+
// (existing + amount), so re-observing the same WITHDRAWN event must
633+
// NOT re-apply it. Check for the recorded event first and bail out
634+
// before touching the balance when it already exists.
635+
const existingEvent = await tx.streamEvent.findUnique({
636+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
637+
select: { id: true },
638+
});
639+
if (existingEvent) {
640+
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=WITHDRAWN`);
641+
return false;
642+
}
643+
644+
const stream = await tx.stream.findUniqueOrThrow({
645+
where: { streamId },
646+
select: { withdrawnAmount: true },
647+
});
640648

641-
const stream = await tx.stream.findUniqueOrThrow({
642-
where: { streamId },
643-
select: { withdrawnAmount: true },
644-
});
649+
const newWithdrawnAmount = (
650+
BigInt(stream.withdrawnAmount) + BigInt(amount)
651+
).toString();
645652

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

650-
await tx.stream.update({
651-
where: { streamId },
652-
data: {
653-
withdrawnAmount: newWithdrawnAmount,
654-
lastUpdateTime: timestamp,
655-
},
656-
});
661+
await tx.streamEvent.upsert({
662+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
663+
create: {
664+
streamId,
665+
eventType: 'WITHDRAWN',
666+
amount,
667+
transactionHash: event.txHash,
668+
ledgerSequence: event.ledger,
669+
timestamp,
670+
metadata: JSON.stringify({ recipient }),
671+
},
672+
update: {},
673+
});
657674

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-
});
671-
});
675+
return true;
676+
},
677+
);
678+
679+
// Skip re-broadcasting SSE for an already-recorded (duplicate) event.
680+
if (!applied) return;
672681

673682
sseService.broadcastToStream(String(streamId), 'stream.withdrawn', {
674683
streamId,

0 commit comments

Comments
 (0)