Skip to content

Vault balance update is a read-modify-write race: concurrent deposits lose funds #421

Description

@YaronZaki

Labels / Complexity: bug, financial, database, Backend, reliability · Extremely High — 500

Problem

DepositDBConnector.add_vault_balance in quantara/web_app/db/crud/deposit.py is a non-atomic read-modify-write:

vault = self.get_vault(wallet_id, symbol)        # 1. read current amount
...
new_amount = Decimal(vault.amount) + Decimal(amount)   # 2. add in Python
db.query(Vault).filter_by(id=vault.id).update(amount=str(new_amount))  # 3. write back
db.commit()

Two concurrent requests for the same (wallet_id, symbol) both read the same vault.amount, each compute old + their_amount, and the second write overwrites the first — a lost update. On a money ledger this silently discards one of the deposits.

A related defect is in the data model and the deposit path: create_vault (quantara/web_app/db/crud/deposit.py) always inserts a new Vault row on every /api/vault/deposit, and the Vault table (quantara/web_app/db/models.py) has no unique constraint on (user_id, symbol). get_vault returns .first(), so once more than one row exists for a user+symbol, add_vault_balance updates an arbitrary row while others accumulate stale balances — the total is never the sum the user actually deposited.

The codebase already contains the correct pattern for exactly this situation: add_extra_deposit_to_position in quantara/web_app/db/crud/position.py uses a PostgreSQL insert(...).on_conflict_do_update(...) with cast(amount, Numeric) + cast(amount, Numeric) to atomically upsert. The vault path does not reuse it.

Root cause

vault = self.get_vault(wallet_id, symbol)          # ← read outside the write transaction
new_amount = Decimal(vault.amount) + Decimal(amount)  # ← lost-update window
db.query(Vault).filter_by(id=vault.id).update(amount=str(new_amount))

Why this is architecturally hard

  1. The shortcut — SELECT ... FOR UPDATE — requires a single transaction that spans the read and the write, but get_vault opens its own Session and closes it before returning, so the contributor must restructure the method into one session/transaction rather than bolting a lock onto the current split.
  2. The deeper fix is an atomic SQL upsert (INSERT ... ON CONFLICT (user_id, symbol) DO UPDATE SET amount = amount + excluded.amount), which requires adding the missing unique constraint to the Vault model — an Alembic migration and a data-cleanup decision for any existing duplicate rows.
  3. amount is stored as a String (Vault.amount and Position.amount are String), so arithmetic must go through Decimal/NUMERIC casting inside SQL, exactly as add_extra_deposit_to_position already does — the contributor must reconcile string storage with atomic numeric addition.
  4. create_vault and add_vault_balance are two divergent write paths (one inserts, one updates-first-row); unifying them into one upsert is the real fix, and the API endpoints in quantara/web_app/api/vault.py must both route through it.

Proposed design

-- one atomic upsert, mirroring ExtraDeposit's pattern
INSERT INTO vault (user_id, symbol, amount)
VALUES (:user_id, :symbol, :amount)
ON CONFLICT (user_id, symbol)
DO UPDATE SET amount = cast(vault.amount AS numeric) + cast(EXCLUDED.amount AS numeric);

The maintainer decision is whether to add a unique constraint on (user_id, symbol) and migrate existing rows, or to key the upsert by an explicit natural key.

Downstream impact

Adding a unique constraint on (user_id, symbol) requires an Alembic migration (quantara/web_app/alembic/) and reconciliation of any existing duplicate Vault rows. The response shape of /api/vault/deposit and /api/vault/add_balance need not change.

Acceptance criteria

Database

  • A unique constraint (or equivalent natural key) exists on (user_id, symbol) for Vault.
  • add_vault_balance (or its replacement) performs the increment atomically in SQL.

Service

  • Concurrent increments to the same (wallet_id, symbol) are not lost: N concurrent deposits of X produce a final balance of N*X.
  • create_vault and add_vault_balance converge on a single upsert path.

Tests

  • A concurrency test (or a transaction-isolation test) demonstrates no lost update.
  • Tests run via cd quantara && poetry run pytest web_app/tests -k vault.

Out of scope

Do not migrate Position.amount/ExtraDeposit.amount storage in this issue; only fix the Vault balance path.

Getting started

Files in scope: quantara/web_app/db/crud/deposit.py, quantara/web_app/db/models.py, quantara/web_app/api/vault.py, plus an Alembic migration. Verify with:

cd quantara && poetry run pytest web_app/tests -k vault

Good first files to read: quantara/web_app/db/crud/deposit.py, quantara/web_app/db/crud/position.py (the on_conflict_do_update pattern), quantara/web_app/db/models.py.

Activity

  1. added
    bugSomething isn't working
    databaseImported from PRODUCTION_ISSUES.md
    financialImported from PRODUCTION_ISSUES.md
    reliabilityImported from PRODUCTION_ISSUES.md
    on Aug 20, 2026
  2. heymide commented on Aug 20, 2026

    @heymide

    I'd like to be assigned to this issue. I'll deliver a clean, maintainable solution that matches the requirements.

    Payout wallet (Stellar): GCJS6J54NK4FJFT5I4AUIOSFONKAM7GI6RRHWYH55TUY2WZAEIH2Q5D7
    GrantFox OSS campaign — smart escrow payout on merge.

  3. TechBroAfrica commented on Aug 20, 2026

    @TechBroAfrica

    Focused on building trust through consistent, meaningful contributions. I approach every issue with curiosity, attention to detail, and a commitment to delivering clean, maintainable, and well-tested solutions. Driven by impact, not just commits, I value clear communication, effective collaboration with maintainers, continuous learning, and delivering meaningful value to every project I contribute to.

  4. soma-enyi commented on Aug 20, 2026

    @soma-enyi

    Hi team, I'd like to work on this issue. I'm a full-stack developer experienced in TypeScript, Rust, and smart contract development across EVM and Soroban/Stellar ecosystems. I've built and shipped production systems covering backend APIs, frontend interfaces (Next.js, React), CI/CD pipelines, and onchain protocols — so I'm comfortable working across whatever layer this issue touches. My approach I'll start by reproducing the issue locally and reading the surrounding code to understand context and constraints. I'll scope the fix tightly to avoid unnecessary churn, write or update tests to cover the change, and make sure linting, formatting, and existing CI checks all pass before opening a PR. If anything in the issue is ambiguous, I'll flag it early rather than guess. What you can expect A single clean PR with a clear description linking back to this issue. Minimal, well-tested changes that follow the project's existing conventions. Fast turnaround — I'm available to start immediately and responsive to review feedback. Happy to discuss the approach further if needed. Looking forward to contributing.

  5. portableDD commented on Aug 20, 2026

    @portableDD
    Contributor

    can i work on this?

  6. grantfox-oss commented on Aug 20, 2026

    @grantfox-oss

    🦊 GrantFox — @portableDD has been assigned to this issue as part of the Third Campaign campaign!

    Next steps:

    1. Open a Pull Request referencing this issue (e.g., Closes #421)
    2. Your PR will be reviewed by the Quantarq maintainers

    Good luck! Track your progress on GrantFox.

  7. added 2 commits that reference this issue on Aug 20, 2026
    42bfd64
    3bb1e41
  8. grantfox-oss commented on Aug 22, 2026

    @grantfox-oss

    🎉 This issue has been marked as completed on GrantFox as part of the Third Campaign campaign!

    @portableDD's PR #434 was approved and merged by @YaronZaki.

    🏆 @portableDD: You earned 40 FoxPoints for this contribution! Your current tier: Builder (1,105 total points). Track your full progress on GrantFox.

    👏 Great work, @portableDD! Keep contributing to Quantarq.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BackendGrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignbugSomething isn't workingdatabaseImported from PRODUCTION_ISSUES.mdfinancialImported from PRODUCTION_ISSUES.mdreliabilityImported from PRODUCTION_ISSUES.md

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions