Skip to content

Commit 6a4e061

Browse files
authored
Merge pull request #1071 from synonymdev/fix/hw-sign-fee-display
fix: show mining fee in hw sign fee grid
2 parents ebde2ae + 56ae8bd commit 6a4e061

5 files changed

Lines changed: 191 additions & 8 deletions

File tree

app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import to.bitkit.ui.screens.transfer.previewBtOrder
3636
import to.bitkit.ui.theme.AppThemeSurface
3737
import to.bitkit.ui.theme.Colors
3838
import to.bitkit.ui.utils.withAccent
39+
import to.bitkit.models.safe
3940
import to.bitkit.viewmodels.TransferViewModel
4041

4142
@Composable
@@ -54,8 +55,9 @@ fun SpendingHwSignScreen(
5455
return
5556
}
5657

57-
LaunchedEffect(deviceId) {
58+
LaunchedEffect(deviceId, order.id) {
5859
viewModel.warmUpHardwareConnection(deviceId)
60+
viewModel.updateHwFundingFeeEstimate(order, deviceId)
5961
}
6062

6163
DisposableEffect(viewModel) {
@@ -64,6 +66,7 @@ fun SpendingHwSignScreen(
6466

6567
Content(
6668
order = order,
69+
miningFeeSats = state.hwMiningFeeSats,
6770
isAdvanced = state.isAdvanced,
6871
isSigning = state.isSigning,
6972
hasPendingBroadcast = state.hasPendingHwBroadcast,
@@ -78,6 +81,7 @@ fun SpendingHwSignScreen(
7881
@Composable
7982
private fun Content(
8083
order: IBtOrder,
84+
miningFeeSats: ULong = 0uL,
8185
isAdvanced: Boolean = false,
8286
isSigning: Boolean = false,
8387
hasPendingBroadcast: Boolean = false,
@@ -119,7 +123,10 @@ private fun Content(
119123
)
120124
VerticalSpacer(16.dp)
121125

122-
SpendingHwFeeGrid(order = order)
126+
SpendingHwFeeGrid(
127+
order = order,
128+
miningFeeSats = miningFeeSats,
129+
)
123130

124131
VerticalSpacer(24.dp)
125132

@@ -171,19 +178,23 @@ private fun Content(
171178
internal fun SpendingHwFeeGrid(
172179
order: IBtOrder,
173180
modifier: Modifier = Modifier,
181+
miningFeeSats: ULong = 0uL,
174182
) {
183+
val lspFee = order.feeSat.safe() - order.clientBalanceSat.safe()
184+
val total = order.feeSat.safe() + miningFeeSats.safe()
185+
175186
Column(modifier = modifier) {
176187
Row(
177188
horizontalArrangement = Arrangement.spacedBy(16.dp),
178189
modifier = Modifier.height(IntrinsicSize.Min)
179190
) {
180191
FeeInfo(
181192
label = stringResource(R.string.lightning__spending_confirm__network_fee),
182-
amount = order.networkFeeSat.toLong(),
193+
amount = miningFeeSats.toLong(),
183194
)
184195
FeeInfo(
185196
label = stringResource(R.string.lightning__spending_confirm__lsp_fee),
186-
amount = order.serviceFeeSat.toLong(),
197+
amount = lspFee.toLong(),
187198
)
188199
}
189200
Row(
@@ -196,12 +207,28 @@ internal fun SpendingHwFeeGrid(
196207
)
197208
FeeInfo(
198209
label = stringResource(R.string.lightning__spending_confirm__total),
199-
amount = order.feeSat.toLong(),
210+
amount = total.toLong(),
200211
)
201212
}
202213
}
203214
}
204215

216+
@Preview(showSystemUi = true)
217+
@Composable
218+
private fun PreviewWithMiningFee() {
219+
AppThemeSurface {
220+
Content(
221+
order = previewBtOrder(
222+
networkFeeSat = 528uL,
223+
serviceFeeSat = 132uL,
224+
clientBalanceSat = 7_042uL,
225+
feeSat = 7_402uL,
226+
),
227+
miningFeeSats = 141uL,
228+
)
229+
}
230+
}
231+
205232
@Preview(showSystemUi = true)
206233
@Composable
207234
private fun Preview() {

app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,17 @@ fun SpendingHwSignedScreen(
5151
onContinue()
5252
}
5353

54-
Content(order = order, onBackClick = onCloseClick)
54+
Content(
55+
order = order,
56+
miningFeeSats = state.hwMiningFeeSats,
57+
onBackClick = onCloseClick,
58+
)
5559
}
5660

5761
@Composable
5862
private fun Content(
5963
order: IBtOrder,
64+
miningFeeSats: ULong = 0uL,
6065
onBackClick: () -> Unit = {},
6166
) {
6267
ScreenColumn {
@@ -85,7 +90,10 @@ private fun Content(
8590
)
8691
VerticalSpacer(16.dp)
8792

88-
SpendingHwFeeGrid(order = order)
93+
SpendingHwFeeGrid(
94+
order = order,
95+
miningFeeSats = miningFeeSats,
96+
)
8997
}
9098
}
9199
}

app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class TransferViewModel @Inject constructor(
100100
fun setTransferEffect(effect: TransferEffect) = viewModelScope.launch { transferEffects.emit(effect) }
101101
var maxLspFee = 0uL
102102
private var hwTransferSignJob: Job? = null
103+
private var hwFeeEstimateJob: Job? = null
103104
private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null
104105
private var activeHwTransferDeviceId: String? = null
105106

@@ -193,11 +194,14 @@ class TransferViewModel @Inject constructor(
193194
spendingBalanceSats = oldOrder.clientBalanceSat,
194195
receivingBalanceSats = receivingAmountSats.toULong(),
195196
).getOrThrow()
197+
hwFeeEstimateJob?.cancel()
198+
hwFeeEstimateJob = null
196199
_spendingUiState.update {
197200
it.copy(
198201
order = newOrder,
199202
defaultOrder = oldOrder,
200203
isAdvanced = true,
204+
hwMiningFeeSats = 0uL,
201205
)
202206
}
203207
setTransferEffect(TransferEffect.OnOrderCreated)
@@ -393,12 +397,15 @@ class TransferViewModel @Inject constructor(
393397
private suspend fun onOrderCreated(order: IBtOrder) {
394398
settingsStore.update { it.copy(lightningSetupStep = 0) }
395399
pendingHwFundingBroadcast = null
400+
hwFeeEstimateJob?.cancel()
401+
hwFeeEstimateJob = null
396402
_spendingUiState.update {
397403
it.copy(
398404
order = order,
399405
isAdvanced = false,
400406
defaultOrder = null,
401407
hasPendingHwBroadcast = false,
408+
hwMiningFeeSats = 0uL,
402409
)
403410
}
404411
setTransferEffect(TransferEffect.OnOrderCreated)
@@ -497,18 +504,23 @@ class TransferViewModel @Inject constructor(
497504

498505
fun onUseDefaultLspBalanceClick() {
499506
val defaultOrder = _spendingUiState.value.defaultOrder
507+
hwFeeEstimateJob?.cancel()
508+
hwFeeEstimateJob = null
500509
_spendingUiState.update {
501510
it.copy(
502511
order = defaultOrder,
503512
defaultOrder = null,
504513
isAdvanced = false,
514+
hwMiningFeeSats = 0uL,
505515
)
506516
}
507517
}
508518

509519
fun resetSpendingState() {
510520
hwTransferSignJob?.cancel()
511521
hwTransferSignJob = null
522+
hwFeeEstimateJob?.cancel()
523+
hwFeeEstimateJob = null
512524
pendingHwFundingBroadcast = null
513525
activeHwTransferDeviceId = null
514526
_spendingUiState.update { TransferToSpendingUiState() }
@@ -520,6 +532,8 @@ class TransferViewModel @Inject constructor(
520532
val deviceId = activeHwTransferDeviceId
521533
hwTransferSignJob?.cancel()
522534
hwTransferSignJob = null
535+
hwFeeEstimateJob?.cancel()
536+
hwFeeEstimateJob = null
523537
_spendingUiState.update { it.copy(isSigning = false) }
524538
if (deviceId != null) {
525539
viewModelScope.launch {
@@ -564,6 +578,41 @@ class TransferViewModel @Inject constructor(
564578
hwWalletRepo.warmUpKnownDevice(deviceId)
565579
}
566580

581+
/** Best-effort offline mining-fee estimate for the Sign screen (xpub compose, no device session). */
582+
fun updateHwFundingFeeEstimate(order: IBtOrder, deviceId: String) {
583+
hwFeeEstimateJob?.cancel()
584+
hwFeeEstimateJob = viewModelScope.launch {
585+
if (_spendingUiState.value.hasPendingHwBroadcast) return@launch
586+
val address = order.payment?.onchain?.address.orEmpty()
587+
if (address.isEmpty()) return@launch
588+
val orderId = order.id
589+
590+
runSuspendCatching {
591+
val satsPerVByte = hwFundingSatsPerVByte()
592+
hwWalletRepo.composeFundingTransaction(
593+
deviceId = deviceId,
594+
address = address,
595+
sats = order.feeSat,
596+
satsPerVByte = satsPerVByte,
597+
).getOrThrow().miningFeeSats
598+
}.onSuccess { miningFeeSats ->
599+
_spendingUiState.update { state ->
600+
val activeOrderId = state.order?.id
601+
if ((activeOrderId != null && activeOrderId != orderId) || state.hasPendingHwBroadcast) {
602+
state
603+
} else {
604+
state.copy(hwMiningFeeSats = miningFeeSats)
605+
}
606+
}
607+
}.onFailure {
608+
Logger.debug(
609+
"Skipped offline hardware funding fee estimate for '$deviceId'",
610+
context = TAG,
611+
)
612+
}
613+
}
614+
}
615+
567616
fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) {
568617
if (hwTransferSignJob?.isActive == true) return
569618

@@ -614,6 +663,9 @@ class TransferViewModel @Inject constructor(
614663
val signedTx = pendingHwFundingBroadcast
615664
?.takeIf { it.matches(order, deviceId, address) }
616665
?.signedTx
666+
?.also { pending ->
667+
_spendingUiState.update { state -> state.copy(hwMiningFeeSats = pending.miningFeeSats) }
668+
}
617669
?: prepareSignedHardwareFunding(order, deviceId, address).also {
618670
pendingHwFundingBroadcast = PendingHwFundingBroadcast(
619671
orderId = order.id,
@@ -622,7 +674,12 @@ class TransferViewModel @Inject constructor(
622674
amountSats = order.feeSat,
623675
signedTx = it,
624676
)
625-
_spendingUiState.update { state -> state.copy(hasPendingHwBroadcast = true) }
677+
_spendingUiState.update { state ->
678+
state.copy(
679+
hasPendingHwBroadcast = true,
680+
hwMiningFeeSats = it.miningFeeSats,
681+
)
682+
}
626683
}
627684
broadcastHardwareFunding(signedTx)
628685
}
@@ -645,6 +702,7 @@ class TransferViewModel @Inject constructor(
645702
sats = order.feeSat,
646703
satsPerVByte = satsPerVByte,
647704
)
705+
_spendingUiState.update { it.copy(hwMiningFeeSats = funding.miningFeeSats) }
648706
return signHardwareFunding(deviceId, funding)
649707
}
650708

@@ -1100,6 +1158,7 @@ data class TransferToSpendingUiState(
11001158
val isLoading: Boolean = false,
11011159
val isSigning: Boolean = false,
11021160
val hasPendingHwBroadcast: Boolean = false,
1161+
val hwMiningFeeSats: ULong = 0uL,
11031162
val receivingAmount: Long = 0,
11041163
val feeEstimate: Long? = null,
11051164
)

app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,86 @@ class TransferViewModelTest : BaseUnitTest() {
220220
verify(blocktankRepo).estimateOrderFee(eq(ON_CHAIN_BALANCE.safe() - fallbackReserve.safe()), any(), any())
221221
}
222222

223+
@Test
224+
fun `updateHwFundingFeeEstimate sets mining fee before signing`() = test {
225+
val order = previewBtOrder()
226+
val funding = HwFundingTransaction(
227+
psbt = "psbt",
228+
miningFeeSats = MINING_FEE,
229+
feeRate = FEE_RATE.toFloat(),
230+
totalSpent = order.feeSat + MINING_FEE,
231+
satsPerVByte = FEE_RATE,
232+
)
233+
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
234+
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding))
235+
236+
sut.updateHwFundingFeeEstimate(order, DEVICE_ID)
237+
advanceUntilIdle()
238+
239+
assertEquals(MINING_FEE, sut.spendingUiState.value.hwMiningFeeSats)
240+
verify(hwWalletRepo).composeFundingTransaction(
241+
eq(DEVICE_ID),
242+
eq(order.payment?.onchain?.address.orEmpty()),
243+
eq(order.feeSat),
244+
eq(FEE_RATE),
245+
)
246+
verify(hwWalletRepo, never()).signFunding(any(), any())
247+
}
248+
249+
@Test
250+
fun `updateHwFundingFeeEstimate ignores superseded estimate`() = test {
251+
val orderA = previewBtOrder()
252+
val orderB = previewBtOrder().copy(id = "order-b-id")
253+
val staleCompose = CompletableDeferred<Result<HwFundingTransaction>>()
254+
val fundingB = HwFundingTransaction(
255+
psbt = "psbt-b",
256+
miningFeeSats = 999uL,
257+
feeRate = FEE_RATE.toFloat(),
258+
totalSpent = orderB.feeSat + 999uL,
259+
satsPerVByte = FEE_RATE,
260+
)
261+
val staleFunding = HwFundingTransaction(
262+
psbt = "psbt-a",
263+
miningFeeSats = MINING_FEE,
264+
feeRate = FEE_RATE.toFloat(),
265+
totalSpent = orderA.feeSat + MINING_FEE,
266+
satsPerVByte = FEE_RATE,
267+
)
268+
269+
whenever(blocktankRepo.calculateLiquidityOptions(any()))
270+
.thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE)))
271+
whenever(blocktankRepo.createOrder(any(), any(), any()))
272+
.thenReturn(Result.success(orderA))
273+
.thenReturn(Result.success(orderB))
274+
whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE))
275+
whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).doSuspendableAnswer {
276+
if (sut.spendingUiState.value.order?.id == orderA.id) {
277+
staleCompose.await()
278+
} else {
279+
Result.success(fundingB)
280+
}
281+
}
282+
283+
sut.onConfirmAmount(OPTION_MAX_CLIENT_BALANCE.toLong())
284+
advanceUntilIdle()
285+
286+
sut.updateHwFundingFeeEstimate(orderA, DEVICE_ID)
287+
runCurrent()
288+
289+
sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong())
290+
advanceUntilIdle()
291+
292+
sut.updateHwFundingFeeEstimate(orderB, DEVICE_ID)
293+
advanceUntilIdle()
294+
295+
assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats)
296+
297+
staleCompose.complete(Result.success(staleFunding))
298+
advanceUntilIdle()
299+
300+
assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats)
301+
}
302+
223303
@Test
224304
fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test {
225305
val order = previewBtOrder()
@@ -249,6 +329,7 @@ class TransferViewModelTest : BaseUnitTest() {
249329
sut.onTransferToSpendingHwConfirm(order, DEVICE_ID)
250330
advanceUntilIdle()
251331

332+
assertEquals(MINING_FEE, sut.spendingUiState.value.hwMiningFeeSats)
252333
verify(hwWalletRepo).composeFundingTransaction(
253334
eq(DEVICE_ID),
254335
eq(order.payment?.onchain?.address.orEmpty()),
@@ -910,6 +991,13 @@ class TransferViewModelTest : BaseUnitTest() {
910991
maxClientBalanceSat = maxClientBalanceSat,
911992
)
912993

994+
private fun liquidityOptionsForCreate(maxClientBalanceSat: ULong) = ChannelLiquidityOptions(
995+
defaultLspBalanceSat = LSP_BALANCE,
996+
minLspBalanceSat = LSP_BALANCE,
997+
maxLspBalanceSat = LSP_BALANCE,
998+
maxClientBalanceSat = maxClientBalanceSat,
999+
)
1000+
9131001
private fun btInfo(lspMaxClientBalance: ULong): IBtInfo {
9141002
val options = mock<IBtInfoOptions>()
9151003
whenever(options.maxClientBalanceSat).thenReturn(lspMaxClientBalance)

changelog.d/next/1071.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hardware wallet transfer sign screens now show the on-chain mining fee and total outflow that matches Trezor confirmation.

0 commit comments

Comments
 (0)