Merge order: merge this MR first. !105 (merged) and !106 (merged) target this branch; GitLab retargets them to
developonce 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 getupdated_atbumped, so one idle row cannot starve the batch. -
TIMEOUT is escalated, not refunded. A TIMEOUT row moves to
NEEDS_MANUAL_REVIEWwith 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
updateExistingTransactionandresolveFailedWithdrawalSubmissionlock thefireblocks_transactionsrow (pessimistic_write) and re-checkhasRefundEntryinside the transaction. Migration1790400000000adds the partial unique indexUQ_fireblocks_ledger_refund_onceonfireblocks_ledger_entries(fireblocks_transaction_id) WHERE entry_type = 'REFUND'. -
Legacy
POST /transactions/withdrawreturns 404. It moved fiat intolockedBalancewith no processor behind it, and a negative amount credited the user.CheckWithdrawalDtoandAssetsService.lockBalancenow reject zero and negative amounts as well. -
Network fee is stored from
feeInfo.networkFeeon 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-v2validates and formats amounts with BigNumber from string balances (6 dp, round down) viasrc/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
existingTxloaded before the lock, so it can revert a concurrentnetworkFee/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.
-
updateExistingTransactiondoes not treatNEEDS_MANUAL_REVIEWas 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 buildclean, eslint 0 problems on changed files -
FE: 56 tests passed, tsc --noEmitclean -
Review gate passed after 2 rounds