The short version
The app has several places where a user can say "use everything". They don't all work the same way, and some of them leave the wallet showing money it no longer has.
Two techniques are in use:
- A true drain — spend every coin, send
everything minus the fee, leave no change. The wallet ends at exactly zero.
- A fee reserve — hold back a small amount for the fee, send the rest, let the leftover return as change. The wallet ends at almost zero.
Drains are the honest answer to "send everything". Right now they break the balance, for the reason below.
Where "Max" appears
| What the user sees |
Kotlin SDK function used |
True drain? |
Known problem |
| Send screen → Max, to someone else's address |
sendAllToAddress → CoreSendAllNative.buildSignBroadcastSendAll |
Yes |
Balance stays wrong after sending. The spent coins keep being counted, and the transaction sticks on "Sending" forever. |
| Send screen → Max, to your own address |
same as above |
Yes |
None. Paying yourself creates an output the wallet owns, which is exactly what keeps the balance correct. |
| CoinJoin → move mixed funds out |
drainCoinJoinAccountTo → CoreSendAllNative.buildSignBroadcastDrainCoinJoin |
Yes |
Same balance bug whenever the destination isn't your own wallet. This migration normally targets your own address, which avoids it. |
| DEX / Maya swap → Max sell |
buildSignedPayment with a fixed amount of spendable − reserve |
No — uses a reserve |
None. It did use a true drain and hit the balance bug twice on mainnet; that is why it was changed. |
| Shield funds → shield everything |
fundFromAssetLock (or fundFromAssetLockFromAccount when a single account is named), amount reduced by assetLockMaxFeeReserve |
No — uses a reserve |
None. |
| Buy Credits → Max |
identityCredits.topUpFromCore |
No — uses a reserve |
None. |
Why a true drain breaks the balance
A drain sends everything and keeps no change, so the transaction pays only the recipient — nothing in it belongs to your wallet.
The wallet finds its transactions in new blocks by looking for outputs paying one of its own addresses. A drain has none, so the wallet never notices the transaction was mined. It stays stuck as "still sending", and since coins are only marked spent once the transaction is seen in a block, the wallet keeps counting money you already sent.
The result: your balance still shows the amount you sent. A rescan doesn't help — it looks for the same thing and misses it the same way.
This happened twice on mainnet with real funds:
a5c99aec… — wallet kept showing an extra 0.07443157 DASH
1f608a9a… — wallet reported 0.05 DASH after emptying itself completely
Both transactions were confirmed and InstantSend-locked on chain. The wallet simply never saw them.
What needs fixing
The real fix belongs in the SPV layer: when scanning new blocks, the wallet should also watch for its own coins being spent, not only for new coins arriving. It already does this for unconfirmed transactions — just not for blocks. Fix that and every drain path above works, and the workarounds stop being necessary.
Until then, any "Max" that pays a third party should hold back a fee reserve instead of draining. That's what the Maya swap now does, and it's the pattern shielding and Buy Credits have always used.
Worth deciding separately: the reserve approach means "Max" doesn't quite empty the wallet — a small amount stays behind. We don't currently tell the user that.
Maya swap: two related pull requests
- dashpay/platform#4324 — adds the Kotlin SDK ability to request a drain and read back the exact amount it will deliver.
- dashpay/rust-dashcore#928 — lets a drain carry a zero-value
OP_RETURN, so a swap memo can ride along with a send-everything transaction.
Both work as designed and are unaffected by the bug above — it lives in the block-scanning layer, not in either of them.
Notes
This describes the app after the SDK cutover, when every path above runs on the Kotlin SDK.
The short version
The app has several places where a user can say "use everything". They don't all work the same way, and some of them leave the wallet showing money it no longer has.
Two techniques are in use:
everything minus the fee, leave no change. The wallet ends at exactly zero.Drains are the honest answer to "send everything". Right now they break the balance, for the reason below.
Where "Max" appears
sendAllToAddress→CoreSendAllNative.buildSignBroadcastSendAlldrainCoinJoinAccountTo→CoreSendAllNative.buildSignBroadcastDrainCoinJoinbuildSignedPaymentwith a fixed amount ofspendable − reservefundFromAssetLock(orfundFromAssetLockFromAccountwhen a single account is named), amount reduced byassetLockMaxFeeReserveidentityCredits.topUpFromCoreWhy a true drain breaks the balance
A drain sends everything and keeps no change, so the transaction pays only the recipient — nothing in it belongs to your wallet.
The wallet finds its transactions in new blocks by looking for outputs paying one of its own addresses. A drain has none, so the wallet never notices the transaction was mined. It stays stuck as "still sending", and since coins are only marked spent once the transaction is seen in a block, the wallet keeps counting money you already sent.
The result: your balance still shows the amount you sent. A rescan doesn't help — it looks for the same thing and misses it the same way.
This happened twice on mainnet with real funds:
a5c99aec…— wallet kept showing an extra 0.07443157 DASH1f608a9a…— wallet reported 0.05 DASH after emptying itself completelyBoth transactions were confirmed and InstantSend-locked on chain. The wallet simply never saw them.
What needs fixing
The real fix belongs in the SPV layer: when scanning new blocks, the wallet should also watch for its own coins being spent, not only for new coins arriving. It already does this for unconfirmed transactions — just not for blocks. Fix that and every drain path above works, and the workarounds stop being necessary.
Until then, any "Max" that pays a third party should hold back a fee reserve instead of draining. That's what the Maya swap now does, and it's the pattern shielding and Buy Credits have always used.
Worth deciding separately: the reserve approach means "Max" doesn't quite empty the wallet — a small amount stays behind. We don't currently tell the user that.
Maya swap: two related pull requests
OP_RETURN, so a swap memo can ride along with a send-everything transaction.Both work as designed and are unaffected by the bug above — it lives in the block-scanning layer, not in either of them.
Notes
This describes the app after the SDK cutover, when every path above runs on the Kotlin SDK.