Skip to content

SDK Cutover: "Send Max" behaves inconsistently across the app, and some paths corrupt the displayed balance #1538

Description

@HashEngineering

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 sendAllToAddressCoreSendAllNative.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 drainCoinJoinAccountToCoreSendAllNative.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions