Send-max computed at the default fee speed, but the drain happens at the selected speed (#1144) - #1147
Send-max computed at the default fee speed, but the drain happens at the selected speed (#1144)#1147coreyphillips wants to merge 2 commits into
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.
| 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.
| 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.
| 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().
| /** 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.
Closes #1144
.../java/to/bitkit/repositories/LightningRepo.kt | 15 +++
.../main/java/to/bitkit/viewmodels/AppViewModel.kt | 27 ++++-
.../to/bitkit/repositories/LightningRepoTest.kt | 49 ++++++++
.../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 132 +++++++++++++++++++++
changelog.d/next/1144.fixed.md | 1 +
5 files changed, 223 insertions(+), 1 deletion(-)