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

Fixes #802

Problem

handleTokensWithdrawn computed newWithdrawnAmount = BigInt(stream.withdrawnAmount) + BigInt(amount) and applied tx.stream.update unconditionally; the dedup check only guarded the StreamEvent row insert, not the financial field.

The admin POST /v1/admin/indexer/replay resets the cursor and re-polls the same ledgers, so every replay re-added amount to withdrawnAmount even though the StreamEvent row was skipped. Result: withdrawnAmount inflates → claimable shrinks → recipient under-paid. The replay endpoint even advertises idempotency.

Fix

  • Check the (transactionHash, WITHDRAWN) dedup guard first and return early on a duplicate, so the additive withdrawnAmount update only runs for a newly-seen event.
  • Insert the StreamEvent before stream.update inside the same transaction. The unique (transactionHash, eventType) constraint makes a concurrent replay fail and roll back the whole transaction, so withdrawnAmount can never be double-applied.

Audit

handleStreamCancelled and handleStreamCompleted both set withdrawnAmount to an absolute value (amount_withdrawn / total_withdrawn), not additively, so they remain idempotent under replay. No change needed.

Tests

Added a worker test that processes the same tokens_withdrawn event twice and asserts withdrawnAmount changes only once (1000 → 1500, unchanged on replay) and stream.update is called exactly once. All soroban-event-worker.test.ts tests pass.

Acceptance criteria

  • withdrawnAmount update is idempotent (mutated only when the StreamEvent row is newly created, in the same transaction)
  • Test processes the same tokens_withdrawn event twice and asserts withdrawnAmount changes only once
  • Audited handleStreamCancelled/handleStreamCompleted — confirmed idempotent

…sCrypt#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.
@jadonamite jadonamite force-pushed the feat/issue-802-withdrawn-idempotency branch from 61b41ae to 5d1e5dd Compare June 30, 2026 09:27
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

1 participant