-
Notifications
You must be signed in to change notification settings - Fork 4
fix: enforce max send fee drains confirmed amount #1147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
6780822
e00b553
96d17bd
f3f402e
b27f23d
bdf6963
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1514,6 +1514,21 @@ class LightningRepo @Inject constructor( | |
| } | ||
| } | ||
|
|
||
| /** 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() | ||
| } | ||
| } | ||
|
Comment on lines
+1517
to
+1530
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This duplicates
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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The drain decision no longer compares the two, so they cannot disagree. |
||
|
|
||
| suspend fun getFeeRateForSpeed( | ||
| speed: TransactionSpeed, | ||
| feeRates: FeeRates? = null, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2082,6 +2082,7 @@ class AppViewModel @Inject constructor( | |
| it.copy( | ||
| amount = amount, | ||
| isAmountInputValid = validateAmount(amount), | ||
| isMaxAmount = false, | ||
| confirmedWarnings = persistentListOf(), | ||
| ) | ||
| } | ||
|
|
@@ -2358,6 +2359,7 @@ class AppViewModel @Inject constructor( | |
| _sendUiState.update { | ||
| it.copy(selectedUtxos = utxos.toImmutableList()) | ||
| } | ||
| refreshMaxSendOnchain() | ||
| refreshOnchainFeeUi() | ||
| setSendEffect(SendEffect.NavigateToConfirm) | ||
| } | ||
|
|
@@ -3240,6 +3242,7 @@ class AppViewModel @Inject constructor( | |
| state.copy( | ||
| amount = 0u, | ||
| isAmountInputValid = false, | ||
| isMaxAmount = false, | ||
| ) | ||
| } | ||
| } | ||
|
|
@@ -3355,6 +3358,9 @@ class AppViewModel @Inject constructor( | |
| return | ||
| } | ||
|
|
||
| // pay the amount & drain flag the refresh settled on, not the ones it is about to replace | ||
| onchainSendRefreshJob?.join() | ||
|
|
||
| val amount = _sendUiState.value.amount | ||
|
|
||
| val lnurl = _sendUiState.value.lnurl | ||
|
|
@@ -3729,13 +3735,14 @@ class AppViewModel @Inject constructor( | |
| amount: ULong, | ||
| tags: List<String> = emptyList(), | ||
| ): Result<Txid> { | ||
| val state = _sendUiState.value | ||
| return lightningRepo.sendOnChain( | ||
| address = address, | ||
| sats = amount, | ||
| speed = _sendUiState.value.speed, | ||
| utxosToSpend = _sendUiState.value.selectedUtxos, | ||
| isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && | ||
| amount == walletRepo.balanceState.value.maxSendOnchainSats, | ||
| speed = state.speed, | ||
| utxosToSpend = state.selectedUtxos, | ||
| feeRates = state.feeRates, | ||
| isMaxAmount = state.payMethod == SendMethod.ONCHAIN && state.isMaxAmount, | ||
| tags = tags, | ||
| ) | ||
| } | ||
|
|
@@ -3787,19 +3794,21 @@ class AppViewModel @Inject constructor( | |
| } | ||
| } | ||
|
|
||
| /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ | ||
| /** Recheck the max sendable, reselect utxos for current amount & speed, then refresh fees using updated utxos */ | ||
| private fun refreshOnchainSendIfNeeded(): Job? { | ||
| val currentState = _sendUiState.value | ||
| if (currentState.payMethod != SendMethod.ONCHAIN || | ||
| currentState.amount == 0uL || | ||
| currentState.address.isEmpty() | ||
| val state = _sendUiState.value | ||
| if (state.payMethod != SendMethod.ONCHAIN || | ||
| state.amount == 0uL || | ||
| state.address.isEmpty() | ||
| ) { | ||
| return null | ||
| } | ||
|
|
||
| updateOnchainFeeUi { it.copy(isLoading = true) } | ||
| onchainSendRefreshJob?.cancel() | ||
| val job = viewModelScope.launch(bgDispatcher, start = CoroutineStart.LAZY) { | ||
| refreshMaxSendOnchain() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The drain decision now lives in Could we join
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in bdf6963. Covered by
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Gate the swipe on the refresh instead of re-deciding the amount after consent The |
||
| val currentState = _sendUiState.value | ||
| // preselect utxos for deterministic fee estimation | ||
| if ( | ||
| currentState.hardwareWalletId == null && | ||
|
|
@@ -3827,6 +3836,48 @@ class AppViewModel @Inject constructor( | |
| return job | ||
| } | ||
|
|
||
| /** | ||
| * Flags the send as a drain when the amount reaches the max sendable to this recipient at the selected speed, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ Drop KDoc/inline comments on private functions AGENTS.md: 'NEVER add code comments to private functions'. New KDoc on private |
||
| * lowering the amount to that max so the confirmed figure matches what the drain delivers. | ||
| */ | ||
| private suspend fun refreshMaxSendOnchain() { | ||
| val state = _sendUiState.value | ||
| if (state.payMethod != SendMethod.ONCHAIN || state.hardwareWalletId != null) return | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Could we add a case that a hardware max send keeps
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added |
||
| if (state.amount == 0uL || state.address.isEmpty()) return | ||
|
|
||
| val max = lightningRepo.estimateMaxSendOnchain( | ||
| address = state.address, | ||
| speed = state.speed, | ||
| feeRates = state.feeRates, | ||
| ).getOrNull()?.takeIf { it > 0uL } | ||
|
|
||
| if (max == null) { | ||
| // without an estimate the cached max is the only max-send signal left | ||
| _sendUiState.update { | ||
| if (it.divergedFrom(state)) return@update it | ||
| it.copy(isMaxAmount = it.amount == walletRepo.balanceState.value.maxSendOnchainSats) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| val isMaxAmount = state.amount >= max | ||
| if (isMaxAmount && state.amount != max) { | ||
| Logger.info( | ||
| "Lowering amount '${state.amount}' to max '$max' at speed '${state.speed.serialized()}'", | ||
| context = TAG, | ||
| ) | ||
| } | ||
| _sendUiState.update { | ||
| if (it.divergedFrom(state)) return@update it | ||
| it.copy(amount = if (isMaxAmount) max else it.amount, isMaxAmount = isMaxAmount) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 A drain sends the balance at send time, not the confirmed max Setting |
||
| } | ||
| } | ||
|
|
||
| private fun SendUiState.divergedFrom(snapshot: SendUiState) = amount != snapshot.amount || | ||
| address != snapshot.address || | ||
| speed != snapshot.speed || | ||
| hardwareWalletId != snapshot.hardwareWalletId | ||
|
|
||
| private suspend fun refreshOnchainFeeUi() = withContext(bgDispatcher) { | ||
| val currentState = _sendUiState.value | ||
| updateOnchainFeeUi { it.copy(isLoading = true) } | ||
|
|
@@ -4809,6 +4860,7 @@ data class SendUiState( | |
| val isAddressInputValid: Boolean = false, | ||
| val amount: ULong = 0u, | ||
| val isAmountInputValid: Boolean = false, | ||
| val isMaxAmount: Boolean = false, | ||
| val isUnified: Boolean = false, | ||
| val canSwitchWallet: Boolean = false, | ||
| val canSwitchFundingSource: Boolean = false, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 Cover fee >= spendable and the coin-selection path
fee >= spendableis untested:spendableSats.safe() - fee.safe()saturates to 0,takeIf { it > 0uL }makes it look like "no estimate", andrefreshMaxSendOnchainthen falls back toamount == maxSendOnchainSats, which still flags a drain LDK will reject. Fail-closed but unverified - add aLightningRepoTestcase for fee > spendable returning 0, and a send-flow case asserting the fallback does not setisMaxAmountwhen the estimate is unavailable.