Skip to content

fix(indexer): make handleTokensWithdrawn idempotent under replay (#802)#968

Open
jadonamite wants to merge 1 commit into
LabsCrypt:mainfrom
jadonamite:feat/issue-802-withdrawn-idempotency
Open

fix(indexer): make handleTokensWithdrawn idempotent under replay (#802)#968
jadonamite wants to merge 1 commit into
LabsCrypt:mainfrom
jadonamite:feat/issue-802-withdrawn-idempotency

Conversation

@jadonamite

@jadonamite jadonamite commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR resolves a race condition where two indexers (soroban-indexer.service and soroban-event-worker) were running concurrently, leading to a double-counting of withdrawnAmount for the same events. The legacy service has been removed so that only the SorobanEventWorker polls the contract. Additionally, the WITHDRAWN handler has been made idempotent to guarantee that the same event cannot increment the balance more than once.

Root Cause

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 performed a READ-then-ADD on withdrawnAmount in separate transactions, allowing the same event to be applied twice on indexer replay → inflated balance.

Solution

Two complementary strategies ensure idempotency:

  1. Consolidate to single indexer: Delete the legacy soroban-indexer.service.ts and remove its bootstrap from index.ts. Only SorobanEventWorker now polls the contract.

  2. Idempotent withdrawnAmount mutation: Check 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.

Changes

⚙️ Indexer Consolidation

  • [DELETE] backend/src/services/soroban-indexer.service.ts
  • Removed the legacy indexer service to ensure only a single indexer processes events.
  • [MODIFY] backend/src/index.ts
  • Removed the sorobanIndexerService.start() call and related graceful shutdown logic.

🛡️ Idempotency Improvements

  • [MODIFY] backend/src/workers/soroban-event-worker.ts
  • Updated handleTokensWithdrawn to:
    • Check for the existing StreamEvent record before applying the balance increment
    • Return a boolean indicating whether changes were applied
    • Use upsert with unique constraint as idempotency guard
    • Skip SSE broadcast for duplicate events

🧪 Tests

  • [DELETE] backend/tests/soroban-indexer.test.ts
  • Removed obsolete tests for the legacy service.
  • [ADD] backend/tests/single-indexer.regression.test.ts
  • 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

Verification Results

Acceptance Criteria Status
Remove sorobanIndexerService.start()/stop() from index.ts
Delete or disable services/soroban-indexer.service.ts
Add a regression test asserting only one indexer instance polls the contract
Verify withdrawnAmount is not double-incremented when the same WITHDRAWN event is observed
Backend tests pass successfully
Skip duplicate SSE broadcasts on replay

Closes #802

@jadonamite jadonamite force-pushed the feat/issue-802-withdrawn-idempotency branch from 61b41ae to 5d1e5dd Compare June 30, 2026 09:27
@ogazboiz

ogazboiz commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

heads up: main's ci was broken (the Backend CI and Backend Docker Image CI jobs) until the fixes in #969 and #974 just landed, so the red backend/docker checks on this pr are almost certainly stale, they ran against the broken main. please rebase to re-test against the now-green main: git fetch origin && git rebase origin/main && git push --force-with-lease. once it's green i'll review and merge. (if a non-backend check like Frontend CI is still red after the rebase, that part is a real issue worth a look, since frontend ci was passing on main.)

@jadonamite jadonamite force-pushed the feat/issue-802-withdrawn-idempotency branch from 5d1e5dd to 7e0cea5 Compare July 3, 2026 07:39
@jadonamite

Copy link
Copy Markdown
Contributor Author

@ogazboiz Rebased onto the latest main — all checks are green now. Ready for your review.

@ogazboiz ogazboiz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idempotency hardening, moving the dedup findUnique(WITHDRAWN) guard to the top of the transaction and bailing before mutating withdrawnAmount is the right approach. but two things:

  1. it leaves a duplicate SSE broadcast: on a replayed WITHDRAWN event the guard returns inside the $transaction, but sseService.broadcastToStream runs AFTER the transaction and isn't skipped, so a replay still emits a duplicate stream.withdrawn broadcast.
  2. more importantly, #980 supersedes this. it makes the same handleTokensWithdrawn idempotent AND removes the legacy soroban-indexer.service.ts, which is the actual root cause of the double-counting (#801) that this pr leaves running, AND fixes the duplicate-SSE issue above via an applied flag.

since they conflict and #980 is the complete fix, i'm going to land #980 and close this as superseded once it's rebased. you both found the same core issue, appreciate the analysis.

…rawnAmount idempotent (LabsCrypt#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
@jadonamite jadonamite force-pushed the feat/issue-802-withdrawn-idempotency branch from 0ec6617 to 44abe87 Compare July 5, 2026 00:07
@jadonamite

Copy link
Copy Markdown
Contributor Author

@ogazboiz kindly review

@jadonamite jadonamite requested a review from ogazboiz July 6, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Backend] handleTokensWithdrawn increments withdrawnAmount before the dedup guard - replay/reset double-counts and corrupts claimable

2 participants