Skip to content

Commit 5d1e5dd

Browse files
committed
fix(indexer): make handleTokensWithdrawn idempotent under replay (#802)
handleTokensWithdrawn incremented withdrawnAmount before the dedup guard, which only protected the StreamEvent insert. The admin POST /v1/admin/indexer/replay resets the cursor and re-polls processed ledgers, so every replay re-added `amount` even though the event row was skipped — inflating withdrawnAmount and shrinking the recipient's claimable balance. - check the (txHash, WITHDRAWN) dedup guard first and return early on replay, so the financial field is only mutated for a newly-seen event - insert the StreamEvent before the stream.update inside the same transaction; the unique (transactionHash, eventType) constraint makes a concurrent replay roll back the whole transaction, so withdrawnAmount can't be double-applied - add a worker test that processes the same tokens_withdrawn event twice and asserts withdrawnAmount changes only once handleStreamCancelled/handleStreamCompleted audited: both set withdrawnAmount to an absolute value, so they remain idempotent under replay.
1 parent ed02dc6 commit 5d1e5dd

2 files changed

Lines changed: 88 additions & 22 deletions

File tree

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

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,20 @@ export class SorobanEventWorker {
623623
const timestamp = Number(decodeU64(body['timestamp']));
624624

625625
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
626+
// Issue #802: withdrawnAmount is additive, so it must only be mutated when
627+
// this WITHDRAWN event is seen for the first time. Check the dedup guard
628+
// BEFORE touching the financial field — otherwise the admin indexer replay
629+
// (which re-polls already-processed ledgers) re-adds `amount` on every run,
630+
// inflating withdrawnAmount and shrinking the recipient's claimable balance.
631+
const existingEvent = await tx.streamEvent.findUnique({
632+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
633+
select: { id: true },
634+
});
635+
if (existingEvent) {
636+
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=WITHDRAWN`);
637+
return;
638+
}
639+
626640
const stream = await tx.stream.findUniqueOrThrow({
627641
where: { streamId },
628642
select: { withdrawnAmount: true },
@@ -632,35 +646,28 @@ export class SorobanEventWorker {
632646
BigInt(stream.withdrawnAmount) + BigInt(amount)
633647
).toString();
634648

649+
// Insert the event first: the unique (transactionHash, eventType) constraint
650+
// makes a concurrent replay fail here and roll back the whole transaction,
651+
// so withdrawnAmount can never be double-applied.
652+
await tx.streamEvent.create({
653+
data: {
654+
streamId,
655+
eventType: 'WITHDRAWN',
656+
amount,
657+
transactionHash: event.txHash,
658+
ledgerSequence: event.ledger,
659+
timestamp,
660+
metadata: JSON.stringify({ recipient }),
661+
},
662+
});
663+
635664
await tx.stream.update({
636665
where: { streamId },
637666
data: {
638667
withdrawnAmount: newWithdrawnAmount,
639668
lastUpdateTime: timestamp,
640669
},
641670
});
642-
643-
const existingEvent = await tx.streamEvent.findUnique({
644-
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
645-
select: { id: true },
646-
});
647-
if (existingEvent) {
648-
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=WITHDRAWN`);
649-
} else {
650-
await tx.streamEvent.upsert({
651-
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
652-
create: {
653-
streamId,
654-
eventType: 'WITHDRAWN',
655-
amount,
656-
transactionHash: event.txHash,
657-
ledgerSequence: event.ledger,
658-
timestamp,
659-
metadata: JSON.stringify({ recipient }),
660-
},
661-
update: {},
662-
});
663-
}
664671
});
665672

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

backend/tests/soroban-event-worker.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,65 @@ describe('SorobanEventWorker', () => {
159159
);
160160
});
161161

162+
it('applies a tokens_withdrawn event only once under indexer replay (Issue #802)', async () => {
163+
const txHash = 'withdraw-tx-1';
164+
const streamId = 7;
165+
166+
const mockEvent: rpc.Api.EventResponse = {
167+
id: 'withdraw-event-1',
168+
type: 'contract',
169+
ledger: 2000,
170+
ledgerClosedAt: '2024-06-01T00:00:00Z',
171+
txHash,
172+
transactionIndex: 0,
173+
operationIndex: 0,
174+
inSuccessfulContractCall: true,
175+
topic: [
176+
{ switch: () => ({ value: 0 }), sym: () => 'tokens_withdrawn' } as any,
177+
{ switch: () => ({ value: 1 }), u64: () => ({ toString: () => streamId.toString() }) } as any,
178+
],
179+
value: {
180+
switch: () => ({ value: 4 }),
181+
map: () => [
182+
{ key: () => ({ sym: () => 'recipient' }), val: () => ({ address: () => ({ switch: () => ({ value: 0 }), accountId: () => ({ ed25519: () => Buffer.alloc(32) }) }) }) },
183+
{ key: () => ({ sym: () => 'amount' }), val: () => ({ i128: () => ({ hi: () => ({ toString: () => '0' }), lo: () => ({ toString: () => '500' }) }) }) },
184+
{ key: () => ({ sym: () => 'timestamp' }), val: () => ({ u64: () => ({ toString: () => '1700000500' }) }) },
185+
] as any,
186+
} as any,
187+
};
188+
189+
// Simulate persistent DB state across both runs of the same event.
190+
let withdrawn = '1000';
191+
let eventExists = false;
192+
193+
const mockTx = {
194+
streamEvent: {
195+
findUnique: vi.fn().mockImplementation(async () => (eventExists ? { id: 'evt-row' } : null)),
196+
create: vi.fn().mockImplementation(async () => { eventExists = true; return { id: 'evt-row' }; }),
197+
},
198+
stream: {
199+
findUniqueOrThrow: vi.fn().mockImplementation(async () => ({ withdrawnAmount: withdrawn })),
200+
update: vi.fn().mockImplementation(async ({ data }: any) => { withdrawn = data.withdrawnAmount; return { streamId }; }),
201+
},
202+
};
203+
204+
(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementation((cb) => cb(mockTx));
205+
206+
// First processing applies the withdrawal: 1000 + 500 = 1500.
207+
await (worker as any).handleTokensWithdrawn(mockEvent, mockEvent.topic![1]);
208+
expect(withdrawn).toBe('1500');
209+
expect(mockTx.stream.update).toHaveBeenCalledTimes(1);
210+
211+
// Admin replay re-polls the same ledger and re-emits the identical event.
212+
await (worker as any).handleTokensWithdrawn(mockEvent, mockEvent.topic![1]);
213+
expect(withdrawn).toBe('1500'); // unchanged — no double count
214+
expect(mockTx.stream.update).toHaveBeenCalledTimes(1); // financial field not touched again
215+
expect(mockTx.streamEvent.create).toHaveBeenCalledTimes(1);
216+
expect(logger.warn).toHaveBeenCalledWith(
217+
expect.stringContaining('Duplicate StreamEvent skipped')
218+
);
219+
});
220+
162221
it('should persist a zero-rate stream_created event without throwing', async () => {
163222
const txHash = 'zero-rate-tx-hash';
164223
const streamId = 77;

0 commit comments

Comments
 (0)