Skip to content

fix(withdraw): gate balance increment on event INSERT rowcount to prevent double-counting - #1

Open
freebuff-web[bot] wants to merge 1 commit into
mainfrom
fix/issue-1216-idempotent-withdraw
Open

fix(withdraw): gate balance increment on event INSERT rowcount to prevent double-counting#1
freebuff-web[bot] wants to merge 1 commit into
mainfrom
fix/issue-1216-idempotent-withdraw

Conversation

@freebuff-web

@freebuff-web freebuff-web Bot commented Aug 30, 2026

Copy link
Copy Markdown

Problem

The withdraw handler (backend/src/routes/v1/streams/withdraw.ts) atomically incremented withdrawnAmount on every request, regardless of whether the corresponding StreamEvent already existed. Because sorobanWithdraw returns a deterministic txHash ('simulated-withdraw-' + streamId), retries and concurrent duplicate requests would each re-increment the balance — a real fund-accounting bug.

Root cause: The StreamEvent upsert (which was idempotent via the unique constraint on (transactionHash, eventType)) happened after the blind balance increment. A retry would:

  1. Increment withdrawnAmount again (no guard)
  2. Attempt the upsert → no-op (event already exists)

This means withdrawnAmount was double-counted on every retry.

Solution

Move the StreamEvent creation inside the $transaction using a conditional INSERT ... WHERE NOT EXISTS ... RETURNING. The INSERT's rowcount (1 = new event, 0 = duplicate) gates the balance UPDATE, guaranteeing exactly-once accounting per claimable window.

This is race-condition-safe because:

  • The unique constraint on (transactionHash, eventType) prevents duplicate events at the database level
  • The rowcount is evaluated within the same transaction that performs the balance increment
  • Both sequential retries and concurrent duplicate requests are handled correctly

Files Changed

  • backend/src/routes/v1/streams/withdraw.ts — Core fix: moved event creation inside transaction, gated balance increment on INSERT rowcount
  • backend/tests/withdraw.handler.test.ts — Updated existing test + added new idempotency test
  • backend/tests/eventRace.test.ts — Updated race condition test to verify new INSERT-based approach
  • backend/tests/integration/streams/withdraw.test.ts — Updated existing tests + added two new integration tests proving idempotent withdrawal

Tests Added

  1. Unit test (withdraw.handler.test.ts): "should not increment withdrawnAmount when the event already exists (idempotent)" — proves the balance stays at 150 (not 200) when a duplicate request arrives
  2. Integration test ("does not double-count withdrawnAmount when the same claim window is withdrawn twice in a row"): proves that two requests with the same deterministic txHash result in the balance being incremented only once
  3. Integration test ("gates balance increment on event INSERT rowcount — proves duplicate events skip the UPDATE"): explicitly verifies INSERT rowcount is 1 on first request and 0 on second, with the balance unchanged
  4. Race condition test (eventRace.test.ts): "skips balance increment when event already exists (worker race condition)": proves that when the worker creates the event first, the handler's balance increment is skipped

Validation

  • ✅ TypeScript compiles cleanly (tsc --noEmit)
  • ✅ All 40 unit test files pass (326 tests)
  • ✅ All 6 integration withdraw tests pass
  • ✅ No regressions in other test suites

Closes LabsCrypt#1216

🤖 Generated with Codebuff
Co-Authored-By: Codebuff noreply@codebuff.com

…vent double-counting

The withdraw handler atomically incremented withdrawnAmount on every request,
regardless of whether the corresponding StreamEvent already existed. Because
sorobanWithdraw returns a deterministic txHash ('simulated-withdraw-' +
streamId), retries and concurrent duplicate requests would each re-increment
the balance — a real fund-accounting bug.

Move the StreamEvent creation inside the $transaction using a conditional
INSERT with WHERE NOT EXISTS + RETURNING. The INSERT's rowcount (1 = new,
0 = duplicate) gates the balance UPDATE, guaranteeing exactly-once accounting
per claimable window. This is race-condition-safe because the unique
constraint on (transactionHash, eventType) prevents duplicate events at the
database level, and the rowcount is evaluated within the same transaction.

Updates existing tests in eventRace.test.ts and withdraw.handler.test.ts,
and adds two new integration tests proving idempotent withdrawal behavior.

Closes LabsCrypt#1216

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
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.

[Audit] Duplicate/retried withdraw requests double-count withdrawnAmount

1 participant