fix: stop buy-crypto transactions for blocked/deleted users to unblock batch completion#3680
Merged
Merged
Conversation
…k batch completion When a user is deleted or blocked after their transaction enters a payout batch, the batch gets permanently stuck in PayingOut status. The blocked user check throws an error that is caught and silently continued, but the transaction never receives a payout order and can never complete. Since batch completion requires ALL transactions to have isComplete=true, one stuck transaction blocks the entire batch indefinitely. The per-asset batch lock then prevents any new batches for the same output asset from being created. Changes: - Add stop() method to BuyCrypto entity (sets STOPPED + isComplete) - In payoutTransactions(): stop blocked/deleted user transactions instead of throwing, preventing the dead-end from being created - In checkCompletion(): detect and stop blocked/deleted user transactions to unblock existing stuck batches
…tion check isComplete should only be true when the payout is confirmed on-chain. The batch completion check now also accepts STOPPED transactions, so stopped transactions no longer block batch progression.
…ency The existing manual stop in transaction.service.ts set the status directly instead of using the entity method pattern. Use the new stop() method for a single source of truth.
…back When a user is blocked or deleted, the money must be returned via the existing chargeback process. Set amlCheck=Fail with the appropriate AmlReason (USER_BLOCKED, USER_DATA_BLOCKED, or USER_DELETED). Buy-crypto: stop() now accepts optional amlReason parameter. Fiat-output: set amlCheck=Fail on linked BuyFiat entities for blocked users instead of throwing, triggering the sell-crypto chargeback flow.
davidleomay
approved these changes
May 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A race condition in the buy-crypto payout pipeline causes permanent deadlocks when a user is deleted or blocked after their transaction has been added to a payout batch. The same issue affects the fiat-output pipeline (sell-crypto).
Incident: Monero pipeline blocked for 18+ hours
On 2026-05-06, a single deleted user blocked the entire XMR payout pipeline:
121819created (67 EUR → 0.1769 XMR, user399153, userData392122)399153deleted (between transaction creation and payout)61024created, both TX121817and121819set toReadyForPayout121817(user active) → PayoutOrder created ✅121819→user.isBlockedOrDeleted = true→throw Error→ caught → skipped ❌121817completes normally121819stuck in ReadyForPayout, no PayoutOrder ever createdResult: 33 XMR transactions (~2,730 EUR) from different users were blocked and could not be processed.
Root cause (buy-crypto)
Root cause (fiat-output)
The same pattern exists in
setReadyDate(): a blocked user causes athrowthat is caught and logged every minute, without ever resolving the fiat output. The linked BuyFiat entities never get theiramlCheckset toFail, preventing the chargeback process from returning the crypto.Solution
1.
stop()method onBuyCryptoentity with AML reasonSets
status = STOPPEDand optionallyamlCheck = Failwith the appropriateAmlReason:USER_DELETED— user account deletedUSER_BLOCKED— user account blockedUSER_DATA_BLOCKED— userData blocked or risk-blockedDoes not set
isComplete— reserved exclusively for confirmed on-chain payouts.2. Buy-crypto payout: stop and trigger chargeback (prevention + recovery)
amlCheck = FailSTOPPEDtransactions alongsideisComplete3. Fiat-output: set
amlCheck = Failon BuyFiat entities (sell-crypto chargeback)Instead of throwing (and repeating every minute), the blocked user's linked BuyFiat entities get
amlCheck = Failwith the appropriate reason. This triggers the existing sell-crypto chargeback flow to return the crypto.4. Consistency: use entity method in transaction service
The existing
TransactionService.stop()set status directly. Updated to use the entitystop()method.Files changed
buy-crypto.entity.tsstop()accepts optionalamlReason, setsamlCheck = Failbuy-crypto-out.service.tsfiat-output-job.service.tsamlCheck = Failon BuyFiat for blocked users instead of throwingtransaction.service.tsstop()methodVerified: no side effects
status: COMPLETEoramlCheck: PENDING— no false matchesSTOPPEDinrefreshFee()andfillPaymentLinkPayments();amlCheck = Failprevents re-processingSTOPPEDtoTransactionState.STOPPEDTest plan
61024completes automatically (TX121819gets stopped withamlCheck = Fail)amlCheck = Failwith correctamlReasonamlCheck = Failon linked BuyFiatsisCompleteremainsfalsefor stopped transactions