fix: enforce max send fee drains confirmed amount - #1147
Conversation
Greptile SummaryThe PR prevents an on-chain max send from draining at a fee speed different from the speed used to calculate the confirmed amount.
Confidence Score: 5/5The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking defects identified. The selected-speed estimate is passed consistently into the drain decision, subtraction safely saturates at zero, and mismatched or unavailable estimates avoid sending more than the amount the user confirmed.
|
| Filename | Overview |
|---|---|
| app/src/main/java/to/bitkit/repositories/LightningRepo.kt | Adds selected-speed maximum estimation using the spendable balance and existing saturating fee subtraction. |
| app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | Revalidates cached max amounts against the selected fee speed before enabling on-chain drain mode. |
| app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt | Covers selected-speed fee subtraction and the zero-spendable-balance shortcut. |
| app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | Covers drain eligibility when estimates match and exact-send fallback on mismatch or estimation failure. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[User confirms cached max amount] --> B{Amount equals cached max?}
B -- No --> E[Send exact amount]
B -- Yes --> C[Estimate max at selected fee speed]
C --> D{Estimate succeeds and matches?}
D -- Yes --> F[Send using drain mode]
D -- No --> E
E --> G[LightningRepo sendOnChain]
F --> G
Reviews (1): Last reviewed commit: "fix: verify max onchain send at selected..." | Re-trigger Greptile
|
Review by codex (round 1): The approach is sound, but the actual drain can still use a different fee-rate snapshot than the new max validation. Existing test results show 1,919 tests passing, including both affected suites. A fresh targeted run was blocked by the sandbox denying the Gradle cache lock.
|
|
Applied the review's blocking fix: sendOnchain now snapshots the send state once and passes the same speed and feeRates to both estimateMaxSendOnchain (the drain guard) and sendOnChain, so the guard and the transaction can no longer be built from different fee tables. Updated the send-flow test to use a non-null FeeRates and assert both calls receive it. Targeted unit tests and detekt pass; committed as e00b553, not pushed.
|
|
Machine state for resuming this PR. Safe to ignore. {
"version": 1,
"round": 1,
"next_actor": "claude",
"status": "pending",
"ledger": {},
"filed": []
} |
|
Review by claude (round 2): The PR mirrors the iOS approach: recompute the max sendable amount at confirm time with the selected speed/fee rates and fall back to an exact-amount send when the cached max no longer matches. Direction is right, plumbing
|
|
Review by claude (round 2): The PR stops the wallet from draining at a fee speed the confirmed amount didn't account for by recomputing the max at the selected speed/rates and only setting isMaxAmount when it still matches exactly, and it now threads state.feeRates into sendOnChain so the check and the send use the same rates. The repo helper is clean and the new unit tests pass (verified: ./gradlew :app:testDevDebugUnitTest --tests '*AppViewModelSendFlowTest' --tests '*LightningRepoTest' -> BUILD SUCCESSFUL). The problem is the fallback: when the recomputed max is lower than the confirmed amount, the code sends the stale exact amount, which cannot fund itself, so the send fails instead of sending the wrong amount. That converts the issue's exact scenario (max send, then pick Fast) from 'sends less than displayed' into 'send always errors', and the address asymmetry makes it fire even without a speed change. The guard direction is right; the fallback needs rethinking (recompute/refresh the displayed amount, or drain when the recomputed max is <= the confirmed amount).
|
jvsena42
left a comment
There was a problem hiding this comment.
Reviewed the drain/max-send change and tested it on device (dev build, regtest, funded wallet). One of the findings is a reproduced regression — details inline.
| val maxAtSelectedSpeed = lightningRepo.estimateMaxSendOnchain( | ||
| address = address, | ||
| speed = state.speed, | ||
| feeRates = state.feeRates, |
There was a problem hiding this comment.
Address-type mismatch makes this comparison apples-to-oranges — max-send is broken for P2TR/P2SH/P2PKH recipients.
This recomputes the max using the recipient address, but the cached value it gets compared against on L2897 (walletRepo.balanceState.value.maxSendOnchainSats) comes from DeriveBalanceStateUseCase.getMaxSendAmount, which passes address = null and therefore falls back to cacheStore.onchainAddress — our own receive address. calculateSendAllFee depends on the output script size, so the two values differ for any recipient whose script type differs from selectedAddressType, with no speed change at all.
Reproduced on regtest (balance 1,000,000 sats, default speed):
- P2TR recipient →
Sending exact amount '999890' instead of draining, max at speed 'Medium' is '999878'→isMaxAmount = false→AppError='The available funds are insufficient to cover the transaction'and an "Error Sending" toast. - P2WPKH recipient, same wallet and speed →
isMaxAmount = true, drain succeeds (txid498ced33…).
So MAX now fails for P2TR/P2SH/P2PKH recipients, and symmetrically for P2WPKH recipients once the user switches selectedAddressType to Taproot. iOS avoids this because its MAX amount is itself derived from calculateMaxSendableAmount(address: recipient, rate: selected).
Suggest comparing like with like: either recompute with the same address the cached max used, or derive the MAX button amount from the recipient + selected rate.
There was a problem hiding this comment.
Good catch, fixed. Dropped the equality check entirely. The max is now recomputed for the recipient address at the selected speed in refreshMaxSendOnchain, so the cached own-address value never gates the drain. It only remains as a fallback when the estimate is unavailable.
| if (amount != maxAtSelectedSpeed) { | ||
| Logger.info( | ||
| "Sending exact amount '$amount' instead of draining, " + | ||
| "max at speed '${state.speed}' is '$maxAtSelectedSpeed'", | ||
| context = TAG, | ||
| ) | ||
| return false | ||
| } |
There was a problem hiding this comment.
The intended "fall back to an exact-amount send" path can never succeed.
Whenever maxAtSelectedSpeed < amount, the headroom left over is exactly the 1-output send-all fee at the default rate, while an exact-amount send needs a 2-output tx (recipient + change) at the selected — higher — rate. That is always more, so returning false here doesn't degrade to an exact-amount send, it degrades to an insufficient-funds error. This is what the P2TR repro above ends in.
Consider clamping amount to maxAtSelectedSpeed (and updating the displayed amount/fee when the speed changes, as iOS does), or surfacing an actionable "reduce amount" error instead of the generic LDK failure.
There was a problem hiding this comment.
Agreed, that path was unfundable by construction. There is no exact-amount fallback now: when the amount reaches the max at the selected speed we lower the amount to that max and drain, so the confirmed figure matches what is delivered.
| speed = state.speed, | ||
| feeRates = state.feeRates, | ||
| ).onFailure { | ||
| Logger.warn("Failed to recompute max send amount for speed '${state.speed}'", it, context = TAG) |
There was a problem hiding this comment.
TransactionSpeed has no toString, so this logs Failed to recompute max send amount for speed 'to.bitkit.models.TransactionSpeed$Medium@987bb0b' on device. Use the existing state.speed.serialized() to keep the reference traceable.
There was a problem hiding this comment.
Fixed, the remaining log uses state.speed.serialized().
| if (amount != maxAtSelectedSpeed) { | ||
| Logger.info( | ||
| "Sending exact amount '$amount' instead of draining, " + | ||
| "max at speed '${state.speed}' is '$maxAtSelectedSpeed'", |
There was a problem hiding this comment.
Same here — '${state.speed}' renders as to.bitkit.models.TransactionSpeed$Medium@987bb0b. Use state.speed.serialized().
There was a problem hiding this comment.
Fixed, that log is gone and the one left uses serialized().
| /** Max onchain amount sendable at [speed], i.e. the spendable balance minus the send-all mining fee */ | ||
| suspend fun estimateMaxSendOnchain( | ||
| address: Address? = null, | ||
| speed: TransactionSpeed? = null, | ||
| feeRates: FeeRates? = null, | ||
| ): Result<ULong> = withContext(bgDispatcher) { | ||
| runSuspendCatching { | ||
| val spendableSats = getBalancesAsync().getOrThrow().spendableOnchainBalanceSats | ||
| if (spendableSats == 0uL) return@runSuspendCatching 0uL | ||
|
|
||
| val fee = estimateSendAllFee(address = address, speed = speed, feeRates = feeRates).getOrThrow() | ||
| spendableSats.safe() - fee.safe() | ||
| } | ||
| } |
There was a problem hiding this comment.
This duplicates DeriveBalanceStateUseCase.getMaxSendAmount with different inputs, and the drain decision is exact equality between the two.
getMaxSendAmount (~L214) computes the same quantity but fetches fee rates fresh via blocktank.getFees() and applies the 1%-of-balance fallback; this one takes the send-sheet snapshot state.feeRates (captured in resetSendState) and has no fallback. Since shouldDrainOnchain requires amount == maxAtSelectedSpeed exactly, any blocktank rate refresh between the last balance derivation and confirm silently disables drain — same failure mode as the AppViewModel comments.
Having the use case delegate to this new repo method (same address, same rates) would make the two agree by construction rather than by coincidence.
There was a problem hiding this comment.
The drain decision no longer compares the two, so they cannot disagree. estimateMaxSendOnchain is used on its own for the send-flow max; getMaxSendAmount keeps its fallback for the balance display.
|
@coreyphillips can you look into this (addressing comments), looks quite important on 1st sight, would like to merge and ship it in upcoming 2.5.0 🙏🏻 |
|
@jvsena42 thanks for the device repro, both blockers were real. Pushed 96d17bd. Rather than gating the drain on equality with the cached max, the send flow now recomputes the max for the actual recipient at the selected speed (
Logs now use @ovitrif should be good for 2.5.0 once re-checked. QA notes: max send to a P2TR recipient at default speed should drain, and switching speed on confirm should update the displayed amount. Covered in |
|
@coreyphillips can you please add PR description details matching our usual format? Ensuring sensitive framing, ofc 🙏🏻 While at it please also update with latest master to resolve conflicts flagged by GitHub. |
|
@ovitrif merged latest master, description updated, ready for re-review. |
ovitrif
left a comment
There was a problem hiding this comment.
Pay is not sequenced with refreshMaxSendOnchain, so switching a max send to Fast can still drain at the new fee while the confirm amount is the old one.
The hardware early-return in that helper also has no send-flow test that would fail if the guard disappeared.
| updateOnchainFeeUi { it.copy(isLoading = true) } | ||
| onchainSendRefreshJob?.cancel() | ||
| val job = viewModelScope.launch(bgDispatcher, start = CoroutineStart.LAZY) { | ||
| refreshMaxSendOnchain() |
There was a problem hiding this comment.
The drain decision now lives in refreshMaxSendOnchain, which this job runs in the background. sendOnchain reads state.isMaxAmount without waiting for that job, and the confirm swipe stays enabled while onchainFeeUi.isLoading is true for software wallets. After Max → Confirm → Fast, a swipe that lands before the refresh finishes still calls sendAllToAddress at the new rate while the header still shows the previous amount, which is the mismatch this change is meant to close. The same write can also restore amount after onAmountChange has already cleared isMaxAmount.
Could we join onchainSendRefreshJob before sending, or disable swipe while the on-chain fee refresh is in flight, so the confirmed amount and drain flag are the ones the user actually paid?
There was a problem hiding this comment.
Fixed in bdf6963. proceedWithPayment now joins onchainSendRefreshJob before reading the amount, so the swipe pays the amount and drain flag the refresh settled on. Also guarded the refresh write itself: it no longer applies if amount, address, speed or funding source changed while the estimate was in flight, so it cannot restore an amount onAmountChange already cleared.
Covered by max onchain send waits for the in-flight max refresh before paying and max refresh does not restore the amount after it was edited in AppViewModelSendFlowTest.kt; both fail without the fixes.
| */ | ||
| private suspend fun refreshMaxSendOnchain() { | ||
| val state = _sendUiState.value | ||
| if (state.payMethod != SendMethod.ONCHAIN || state.hardwareWalletId != null) return |
There was a problem hiding this comment.
refreshMaxSendOnchain returns immediately when hardwareWalletId is set, which is what stops a hardware max send from being rewritten using the software wallet's spendable balance. None of the new send-flow tests set a hardware funding source, so removing that guard would not fail the suite.
Could we add a case that a hardware max send keeps hardwareAvailableSats and never calls estimateMaxSendOnchain?
There was a problem hiding this comment.
Added hardware max send keeps its available amount and skips the onchain max estimate in AppViewModelSendFlowTest.kt. It switches speed on a hardware max send and asserts the amount tracks hardwareAvailableSats with no estimateMaxSendOnchain call. Verified it fails if the guard is removed.
|
@ovitrif both addressed in bdf6963.
Three new tests in |
Fixes #1144
This PR keeps max on-chain sends in step with the fee speed and recipient they are sent at, so the amount shown on the confirm screen is the amount that goes out.
Description
Preview
N/A, the only visible change is the confirm amount updating when the fee speed changes.
QA Notes
Manual Tests
bcrt1p...) at the default speed: send completes with no on-chain balance left behind.regression:Send → Amount below max → Confirm → change speed: amount is unchanged and exactly that amount is delivered.regression:Hardware wallet funding source → Amount → tap Max → Confirm → change speed: amount follows the hardware wallet's available balance.regression:Send → Amount → Coin Selection → Continue → Confirm: fee and amount still refresh.Automated Checks
AppViewModelSendFlowTest.kt.LightningRepoTest.kt.just compile,just test,just lint.