Skip to content
GitLab
Projects Groups Topics Snippets
  • /
  • Help
    • Help
    • Support
    • Community forum
    • Submit feedback
    • Contribute to GitLab
  • Register
  • Sign in
  • A Ahad Mobile
  • Project information
    • Project information
    • Activity
    • Labels
    • Members
  • Repository
    • Repository
    • Files
    • Commits
    • Branches
    • Tags
    • Contributor statistics
    • Graph
    • Compare revisions
  • Issues 0
    • Issues 0
    • List
    • Boards
    • Service Desk
    • Milestones
  • Merge requests 0
    • Merge requests 0
  • CI/CD
    • CI/CD
    • Pipelines
    • Jobs
    • Schedules
  • Deployments
    • Deployments
    • Environments
    • Releases
  • Packages and registries
    • Packages and registries
    • Package Registry
    • Terraform modules
  • Monitor
    • Monitor
    • Incidents
  • Analytics
    • Analytics
    • Value stream
    • CI/CD
    • Repository
  • Wiki
    • Wiki
  • Snippets
    • Snippets
  • Activity
  • Graph
  • Create a new issue
  • Jobs
  • Commits
  • Issue Boards
Collapse sidebar
  • holatech
  • Ahad Mobile
  • Merge requests
  • !104

fix(withdraw): poll stuck withdrawals, refund once, close legacy fiat route

  • Review changes

  • Download
  • Patches
  • Plain diff
Merged Khanh Pham requested to merge fix/withdraw-reconcile-stuck into develop Oct 07, 2026
  • Overview 0
  • Commits 10
  • Pipelines 1
  • Changes 23

Merge order: merge this MR first. !105 (merged) and !106 (merged) target this branch; GitLab retargets them to develop once this is merged.

Summary

Fixes from the withdrawal review: crypto withdrawals that got stuck after a lost webhook, a possible double refund, an open legacy fiat route that let a negative amount credit the user, the network fee never being recorded, and float math on withdrawal amounts in the app.

Backend

  • Stuck withdrawals are polled. The reconciliation cron (every 5 min, under withSchedulerLock) now also picks linked WITHDRAW rows in a non-terminal status that have not changed for 30 min (WITHDRAWAL_STALE_SUBMITTED_MS), oldest first, batch of 20. It fetches the Fireblocks status and runs the normal status update (refreshWithdrawalStatus). Unchanged or errored rows get updated_at bumped, so one idle row cannot starve the batch.
  • TIMEOUT is escalated, not refunded. A TIMEOUT row moves to NEEDS_MANUAL_REVIEW with an error log (conditional update, logs only when it changed). We do not know whether funds moved, so an operator decides.
  • One refund per withdrawal. The refund branches of updateExistingTransaction and resolveFailedWithdrawalSubmission lock the fireblocks_transactions row (pessimistic_write) and re-check hasRefundEntry inside the transaction. Migration 1790400000000 adds the partial unique index UQ_fireblocks_ledger_refund_once on fireblocks_ledger_entries(fireblocks_transaction_id) WHERE entry_type = 'REFUND'.
  • Legacy POST /transactions/withdraw returns 404. It moved fiat into lockedBalance with no processor behind it, and a negative amount credited the user. CheckWithdrawalDto and AssetsService.lockBalance now reject zero and negative amounts as well.
  • Network fee is stored from feeInfo.networkFee on webhook/poll, only when it is a finite value > 0.
  • Withdraw/deposit config endpoints add min + fee with BigNumber instead of Number.

App (fe)

  • withdraw-v2 validates and formats amounts with BigNumber from string balances (6 dp, round down) via src/utils/withdraw-amount.ts. The minimum shown is rounded up.

Before deploying to prod

Run this first. The migration fails if any withdrawal already has two REFUND rows (staging has 0):

SELECT fireblocks_transaction_id, count(*)
FROM fireblocks_ledger_entries
WHERE entry_type = 'REFUND'
GROUP BY fireblocks_transaction_id
HAVING count(*) > 1;

Expected after deploy

  • Existing TIMEOUT withdrawals older than 30 min are escalated to NEEDS_MANUAL_REVIEW, one error log each. This is expected.
  • Staging row 3eb54534… (CONFIRMING since 2026-09-08) settles through the new poll.

Known, not fixed here

  • The refund transaction saves the existingTx loaded before the lock, so it can revert a concurrent networkFee/confirmation-count write. Money and status are not affected.
  • The legacy route returns 404 only after the passcode guard, so a wrong passcode there still counts toward the lockout.
  • updateExistingTransaction does not treat NEEDS_MANUAL_REVIEW as settled, so a late event on such a row logs again (pre-existing).
  • The v1 withdraw screens in the app (/withdraw → withdraw-detail) are now dead and will be deleted separately.

Test plan

  • BE unit (fireblocks, transactions, assets, passcode coverage): 391 passed, 21 skipped
  • BE DB specs on moanapay_test (fireblocks): 223 passed, incl. concurrent FAILED events → one REFUND
  • yarn build clean, eslint 0 problems on changed files
  • FE: 56 tests passed, tsc --noEmit clean
  • Review gate passed after 2 rounds

🤖 Generated with Claude Code

Edited Oct 08, 2026 by Khanh Pham
Assignee
Assign to
Reviewers
Request review from
Time tracking
Source branch: fix/withdraw-reconcile-stuck