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.
There was a problem hiding this comment.
🟠 Do not convert exact-amount sends into drains when amount >= max
amount >= max reclassifies any near-max exact-amount send as a drain and rewrites amount. Pre-PR the flag was amount == balanceState.maxSendOnchainSats, so this send failed in LDK with insufficient funds and no funds moved; now it becomes sendAllToAddress(retainReserve = true) for a smaller figure, with no drain indicator anywhere in SendConfirmScreen (isMaxAmount is never read by the UI). Two reachable paths: onAmountContinue (:2321), where a typed amount is silently reduced between the amount screen and Confirm; and the Paykit-request path (:2801), where the lowered amount then fails hasMismatchedIncomingPaymentRequest's strict acceptsPaymentAmount at :3344 and a legitimate request becomes unpayable with a "payment request mismatch" toast. The window is widest when the selected speed differs from defaultTransactionSpeed (the cached max is derived at the default speed, DeriveBalanceStateUseCase.kt:219) or when balanceState is stale-high against the live getBalancesAsync() read inside estimateMaxSendOnchain. iOS is the reference here: SendConfirmationView.shouldUseMaxOnchainSend requires an explicit wallet.isMaxAmountSend intent flag AND amountSats == currentMaxSendable, and never rewrites the amount. Do the same: carry an explicit Max-intent flag from onClickMax, drain only on equality with the max the user was shown, and for any other amount above max set isAmountInputValid = false and surface insufficient savings rather than rewriting the amount.
@Test
fun `exact onchain amount above the max at the selected speed is not lowered into a drain`() = test {
val address = "bcrt1qexactamount"
val requested = 99_800uL
val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u)
balanceState.value = BalanceState(maxSendOnchainSats = 100_000uL)
whenever {
lightningRepo.estimateMaxSendOnchain(address = address, speed = TransactionSpeed.Fast, feeRates = feeRates)
}.thenReturn(Result.success(99_500uL))
whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(20uL))
setSendState(
SendUiState(
address = address,
amount = requested,
isAmountInputValid = true,
payMethod = SendMethod.ONCHAIN,
speed = TransactionSpeed.Medium,
feeRates = feeRates,
),
)
sut.setTransactionSpeed(TransactionSpeed.Fast)
advanceUntilIdle()
assertEquals(requested, sut.sendUiState.value.amount)
assertFalse(sut.sendUiState.value.isMaxAmount)
}Regression test:
@Test
fun `exact onchain amount above the max at the selected speed is not lowered into a drain`() = test {
val address = "bcrt1qexactamount"
val requested = 99_800uL // e.g. BIP21 amount=, below the cached max but above the max at Fast
val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u)
balanceState.value = BalanceState(maxSendOnchainSats = 100_000uL)
whenever {
lightningRepo.estimateMaxSendOnchain(address = address, speed = TransactionSpeed.Fast, feeRates = feeRates)
}.thenReturn(Result.success(99_500uL))
whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(20uL))
setSendState(
SendUiState(
address = address,
amount = requested,
isAmountInputValid = true,
payMethod = SendMethod.ONCHAIN,
speed = TransactionSpeed.Medium,
feeRates = feeRates,
),
)
sut.setTransactionSpeed(TransactionSpeed.Fast)
advanceUntilIdle()
// the requested amount must survive; the send may become invalid, but never a smaller drain
assertEquals(requested, sut.sendUiState.value.amount)
assertFalse(sut.sendUiState.value.isMaxAmount)
}| 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.
There was a problem hiding this comment.
🟡 Gate the swipe on the refresh instead of re-deciding the amount after consent
The join() fixes the ordering but not the consent problem, and it lands too late for the Paykit check. validateIncomingPaymentRequest -> hasMismatchedIncomingPaymentRequest runs at :3344 against the pre-refresh amount using strict acceptsPaymentAmount equality; the join at :3362 then lowers amount and sets isMaxAmount, so a refresh still in flight at swipe time gets the mismatch guard to pass on the requested amount and then underpays it - and completeOnchainPaymentProofInBackground posts a proof for the underpaying txid. The swipe is reachable while the refresh runs because SendConfirmScreen:400 only consults onchainFeeUi.isLoading via isHardwareFeeLoading, which is gated on hardwareWalletId != null. Disable SwipeToConfirm while onchainSendRefreshJob is active (or drop the hardwareWalletId != null qualifier on isHardwareFeeLoading), keep join() only as a backstop, and never change amount after the swipe.
| */ | ||
| 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 |
jvsena42
left a comment
There was a problem hiding this comment.
Reviewed the max-send math end to end. The arithmetic itself is right: estimateMaxSendOnchain works off spendableOnchainBalanceSats (anchor reserve already excluded) minus calculateSendAllFee(retainReserves = true) at the selected speed, wrapped in USat, and the drain uses the same FeeRates snapshot. No reserve stranding, no dust change, no underflow.
The problem is the classification rule, not the math. amount >= max reclassifies any near-max exact-amount send as a drain and rewrites the amount. Verified by running the regression test below against bdf6963: expected:<99800> but was:<99500>, with the other 195 tests in AppViewModelSendFlowTest still passing.
Two of these are replies on the existing threads where the same ground was already covered — they are there because the shipped fix leaves something specific still open, not to re-litigate.
| } | ||
| _sendUiState.update { | ||
| if (it.divergedFrom(state)) return@update it | ||
| it.copy(amount = if (isMaxAmount) max else it.amount, isMaxAmount = isMaxAmount) |
There was a problem hiding this comment.
🟡 A drain sends the balance at send time, not the confirmed max
Setting amount = max makes the confirm screen promise a figure the drain does not honour. isMaxAmount routes to LightningService.send -> node.onchainPayment().sendAllToAddress(...), which ignores sats and sweeps whatever is spendable when it runs - and LightningRepo.sendOnChain calls ensureSyncedBeforeSend() immediately before it, so a deposit that confirms between the estimate and the swipe is swept to the recipient too. Pre-existing for an explicit Max send, but this PR widens the trigger from amount == cachedMax to any amount at or above the live speed-specific max, so ordinary sends now take the sweep path. Re-check the max inside sendOnchain after ensureSyncedBeforeSend and abort (or re-confirm) when it moved above the confirmed amount, rather than letting sendAllToAddress define the amount.
| 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.
🔵 Cover fee >= spendable and the coin-selection path
fee >= spendable is untested: spendableSats.safe() - fee.safe() saturates to 0, takeIf { it > 0uL } makes it look like "no estimate", and refreshMaxSendOnchain then falls back to amount == maxSendOnchainSats, which still flags a drain LDK will reject. Fail-closed but unverified - add a LightningRepoTest case for fee > spendable returning 0, and a send-flow case asserting the fallback does not set isMaxAmount when the estimate is unavailable.
| } | ||
|
|
||
| /** | ||
| * Flags the send as a drain when the amount reaches the max sendable to this recipient at the selected speed, |
There was a problem hiding this comment.
⚪ Drop KDoc/inline comments on private functions
AGENTS.md: 'NEVER add code comments to private functions'. New KDoc on private refreshMaxSendOnchain plus the inline comments at 3361 and 3855. Move the rationale into the function/log names or the PR description.
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.