Skip to content

Commit 43013b8

Browse files
committed
feat: send ln routing fee wip
1 parent 06fcd22 commit 43013b8

File tree

5 files changed

+207
-18
lines changed

5 files changed

+207
-18
lines changed

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,30 @@ class LightningRepo @Inject constructor(
805805
}
806806
}
807807

808+
suspend fun estimateRoutingFees(bolt11: String): Result<ULong> =
809+
executeWhenNodeRunning("estimateRoutingFees") {
810+
Logger.info("Estimating routing fees for bolt11: $bolt11")
811+
lightningService.estimateRoutingFees(bolt11)
812+
.onSuccess {
813+
Logger.info("Routing fees estimated: $it")
814+
}
815+
.onFailure {
816+
Logger.error("Routing fees estimation failed", it)
817+
}
818+
}
819+
820+
suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result<ULong> =
821+
executeWhenNodeRunning("estimateRoutingFeesForAmount") {
822+
Logger.info("Estimating routing fees for amount: $amountSats")
823+
lightningService.estimateRoutingFeesForAmount(bolt11, amountSats)
824+
.onSuccess {
825+
Logger.info("Routing fees estimated: $it")
826+
}
827+
.onFailure {
828+
Logger.error("Routing fees estimation failed", it)
829+
}
830+
}
831+
808832
private companion object {
809833
const val TAG = "LightningRepo"
810834
}

app/src/main/java/to/bitkit/services/LightningService.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,41 @@ class LightningService @Inject constructor(
445445
}
446446
}
447447
}
448+
449+
suspend fun estimateRoutingFees(bolt11: String): Result<ULong> {
450+
val node = this.node ?: throw ServiceError.NodeNotSetup
451+
452+
return ServiceQueue.LDK.background {
453+
return@background try {
454+
val invoice = Bolt11Invoice.fromStr(bolt11)
455+
val feesMsat = node.bolt11Payment().estimateRoutingFees(invoice)
456+
val feeSat = feesMsat / 1000u
457+
Result.success(feeSat)
458+
} catch (e: Exception) {
459+
Result.failure(
460+
if (e is NodeException) LdkError(e) else e
461+
)
462+
}
463+
}
464+
}
465+
466+
suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result<ULong> {
467+
val node = this.node ?: throw ServiceError.NodeNotSetup
468+
469+
return ServiceQueue.LDK.background {
470+
return@background try {
471+
val invoice = Bolt11Invoice.fromStr(bolt11)
472+
val amountMsat = amountSats * 1000u
473+
val feesMsat = node.bolt11Payment().estimateRoutingFeesUsingAmount(invoice, amountMsat)
474+
val feeSat = feesMsat / 1000u
475+
Result.success(feeSat)
476+
} catch (e: Exception) {
477+
Result.failure(
478+
if (e is NodeException) LdkError(e) else e
479+
)
480+
}
481+
}
482+
}
448483
// endregion
449484

450485
// region utxo selection

app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import to.bitkit.ext.DatePattern
5151
import to.bitkit.ext.commentAllowed
5252
import to.bitkit.ext.formatted
5353
import to.bitkit.models.FeeRate
54+
import to.bitkit.models.TransactionSpeed
5455
import to.bitkit.ui.components.BalanceHeaderView
5556
import to.bitkit.ui.components.BiometricsView
5657
import to.bitkit.ui.components.BodySSB
@@ -74,9 +75,10 @@ import to.bitkit.ui.theme.AppThemeSurface
7475
import to.bitkit.ui.theme.Colors
7576
import to.bitkit.ui.utils.rememberBiometricAuthSupported
7677
import to.bitkit.ui.utils.withAccent
77-
import to.bitkit.viewmodels.SanityWarning
7878
import to.bitkit.viewmodels.LnurlParams
79+
import to.bitkit.viewmodels.SanityWarning
7980
import to.bitkit.viewmodels.SendEvent
81+
import to.bitkit.viewmodels.SendFee
8082
import to.bitkit.viewmodels.SendMethod
8183
import to.bitkit.viewmodels.SendUiState
8284
import java.time.Instant
@@ -272,7 +274,9 @@ private fun LnurlCommentSection(
272274
onValueChange = { onEvent(SendEvent.CommentChange(it)) },
273275
minLines = 3,
274276
maxLines = 3,
275-
modifier = Modifier.fillMaxWidth().testTag("CommentInput")
277+
modifier = Modifier
278+
.fillMaxWidth()
279+
.testTag("CommentInput")
276280
)
277281
}
278282

@@ -363,16 +367,22 @@ private fun OnChainDescription(
363367
tint = fee.color,
364368
modifier = Modifier.size(16.dp)
365369
)
366-
uiState.fee.takeIf { it > 0 }
367-
?.let { rememberMoneyText(it) }
368-
?.let {
369-
BodySSB(
370-
text = "${stringResource(fee.title)} ($it)".withAccent(accentColor = Colors.White),
371-
maxLines = 1,
372-
overflow = TextOverflow.MiddleEllipsis,
373-
)
370+
when (val stateFee = uiState.fee) {
371+
is SendFee.OnChain -> {
372+
if (stateFee.value > 0) {
373+
val feeText = rememberMoneyText(stateFee.value)
374+
BodySSB(
375+
text = "${stringResource(fee.title)} ($feeText)".withAccent(accentColor = Colors.White),
376+
maxLines = 1,
377+
overflow = TextOverflow.MiddleEllipsis,
378+
)
379+
} else {
380+
CircularProgressIndicator(Modifier.size(14.dp), Colors.White64, 2.dp)
381+
}
374382
}
375-
?: CircularProgressIndicator(Modifier.size(14.dp), Colors.White64, 2.dp)
383+
384+
else -> CircularProgressIndicator(Modifier.size(14.dp), Colors.White64, 2.dp)
385+
}
376386
Icon(
377387
painterResource(R.drawable.ic_pencil_simple),
378388
contentDescription = null,
@@ -470,7 +480,20 @@ private fun LightningDescription(
470480
tint = Colors.Purple,
471481
modifier = Modifier.size(16.dp)
472482
)
473-
BodySSB(text = "Instant (±$0.01)") // TODO GET FROM STATE
483+
when (val fee = uiState.fee) {
484+
is SendFee.Lightning -> {
485+
val feeText = if (fee.value > 0) rememberMoneyText(fee.value) else null
486+
BodySSB(
487+
text = if (feeText != null) {
488+
"${stringResource(R.string.fee__instant__title)} ($feeText)"
489+
} else {
490+
stringResource(R.string.fee__instant__title)
491+
}
492+
)
493+
}
494+
495+
else -> BodySSB(text = stringResource(R.string.fee__instant__title))
496+
}
474497
}
475498
Spacer(modifier = Modifier.weight(1f))
476499
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
@@ -534,7 +557,6 @@ private fun sendUiState() = SendUiState(
534557
payeeNodeId = null,
535558
description = "Some invoice description",
536559
),
537-
fee = 45554,
538560
)
539561

540562
@Preview(showSystemUi = true, group = "onchain")
@@ -545,6 +567,8 @@ private fun PreviewOnChain() {
545567
Content(
546568
uiState = sendUiState().copy(
547569
selectedTags = listOf("car", "house", "uber"),
570+
fee = SendFee.OnChain(1_234),
571+
speed = TransactionSpeed.Fast,
548572
),
549573
isLoading = false,
550574
showBiometrics = false,
@@ -561,8 +585,10 @@ private fun PreviewOnChainLongFeeSmallScreen() {
561585
BottomSheetPreview {
562586
Content(
563587
uiState = sendUiState().copy(
588+
amount = 2_345_678u,
564589
selectedTags = listOf("car", "house", "uber"),
565-
fee = 654321,
590+
fee = SendFee.OnChain(654_321),
591+
speed = TransactionSpeed.Custom(12_345u),
566592
),
567593
isLoading = false,
568594
showBiometrics = false,
@@ -580,7 +606,7 @@ private fun PreviewOnChainFeeLoading() {
580606
Content(
581607
uiState = sendUiState().copy(
582608
selectedTags = listOf("car", "house", "uber"),
583-
fee = 0,
609+
fee = null,
584610
),
585611
isLoading = false,
586612
showBiometrics = false,
@@ -600,6 +626,7 @@ private fun PreviewLightning() {
600626
amount = 6_543u,
601627
payMethod = SendMethod.LIGHTNING,
602628
selectedTags = emptyList(),
629+
fee = SendFee.Lightning(43),
603630
),
604631
isLoading = false,
605632
showBiometrics = false,

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ class AppViewModel @Inject constructor(
397397
_sendUiState.update {
398398
it.copy(
399399
speed = speed,
400-
fee = fee,
400+
fee = SendFee.OnChain(fee),
401401
selectedUtxos = if (shouldResetUtxos) null else it.selectedUtxos,
402402
)
403403
}
@@ -439,6 +439,8 @@ class AppViewModel @Inject constructor(
439439
}
440440

441441
refreshOnchainSendIfNeeded()
442+
estimateLightningRoutingFeesIfNeeded()
443+
442444
setSendEffect(SendEffect.NavigateToConfirm)
443445
}
444446

@@ -1216,11 +1218,31 @@ class AppViewModel @Inject constructor(
12161218
_sendUiState.update {
12171219
it.copy(
12181220
fees = feesMap,
1219-
fee = currentFee,
1221+
fee = SendFee.OnChain(currentFee),
12201222
)
12211223
}
12221224
}
12231225

1226+
private suspend fun estimateLightningRoutingFeesIfNeeded() {
1227+
val currentState = _sendUiState.value
1228+
if (currentState.payMethod != SendMethod.LIGHTNING) return
1229+
val decodedInvoice = currentState.decodedInvoice ?: return
1230+
1231+
val feeResult = if (decodedInvoice.amountSatoshis > 0uL) {
1232+
lightningRepo.estimateRoutingFees(decodedInvoice.bolt11)
1233+
} else {
1234+
lightningRepo.estimateRoutingFeesForAmount(decodedInvoice.bolt11, currentState.amount)
1235+
}
1236+
1237+
feeResult.onSuccess { fee ->
1238+
_sendUiState.update {
1239+
it.copy(
1240+
fee = SendFee.Lightning(fee.toLong())
1241+
)
1242+
}
1243+
}
1244+
}
1245+
12241246
private suspend fun getFeeEstimate(speed: TransactionSpeed? = null): Long {
12251247
val currentState = _sendUiState.value
12261248
return lightningRepo.calculateTotalFee(
@@ -1506,7 +1528,7 @@ data class SendUiState(
15061528
val speed: TransactionSpeed = TransactionSpeed.default(),
15071529
val comment: String = "",
15081530
val feeRates: FeeRates? = null,
1509-
val fee: Long = 0,
1531+
val fee: SendFee? = null,
15101532
val fees: Map<FeeRate, Long> = emptyMap(),
15111533
)
15121534

@@ -1518,6 +1540,11 @@ enum class SanityWarning(@StringRes val message: Int, val testTag: String) {
15181540
// TODO SendDialog5 https://github.com/synonymdev/bitkit/blob/master/src/screens/Wallets/Send/ReviewAndSend.tsx#L457-L466
15191541
}
15201542

1543+
sealed class SendFee(open val value: Long) {
1544+
data class OnChain(override val value: Long) : SendFee(value)
1545+
data class Lightning(override val value: Long) : SendFee(value)
1546+
}
1547+
15211548
enum class SendMethod { ONCHAIN, LIGHTNING }
15221549

15231550
sealed class SendEffect {

app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,4 +495,80 @@ class LightningRepoTest : BaseUnitTest() {
495495
assertEquals(3, result.size)
496496
assertEquals(mockUtxos, result)
497497
}
498+
499+
@Test
500+
fun `estimateRoutingFees should fail when node is not running`() = test {
501+
val result = sut.estimateRoutingFees("lnbc1u1p0abcde")
502+
assertTrue(result.isFailure)
503+
}
504+
505+
@Test
506+
fun `estimateRoutingFees should succeed when node is running`() = test {
507+
startNodeForTesting()
508+
val testBolt11 = "lnbc1u1p0abcde"
509+
val expectedFeesSats = 50uL
510+
511+
whenever(lightningService.estimateRoutingFees(testBolt11))
512+
.thenReturn(Result.success(expectedFeesSats))
513+
514+
val result = sut.estimateRoutingFees(testBolt11)
515+
516+
assertTrue(result.isSuccess)
517+
assertEquals(expectedFeesSats, result.getOrNull())
518+
verify(lightningService).estimateRoutingFees(testBolt11)
519+
}
520+
521+
@Test
522+
fun `estimateRoutingFees should handle service failure`() = test {
523+
startNodeForTesting()
524+
val testBolt11 = "lnbc1u1p0abcde"
525+
val serviceError = RuntimeException("Service error")
526+
527+
whenever(lightningService.estimateRoutingFees(testBolt11))
528+
.thenReturn(Result.failure(serviceError))
529+
530+
val result = sut.estimateRoutingFees(testBolt11)
531+
532+
assertTrue(result.isFailure)
533+
assertEquals(serviceError, result.exceptionOrNull())
534+
}
535+
536+
@Test
537+
fun `estimateRoutingFeesForAmount should fail when node is not running`() = test {
538+
val result = sut.estimateRoutingFeesForAmount("lnbc1u1p0abcde", 1000uL)
539+
assertTrue(result.isFailure)
540+
}
541+
542+
@Test
543+
fun `estimateRoutingFeesForAmount should succeed when node is running`() = test {
544+
startNodeForTesting()
545+
val testBolt11 = "lnbc1u1p0abcde"
546+
val testAmount = 1000uL
547+
val expectedFeesSats = 25uL
548+
549+
whenever(lightningService.estimateRoutingFeesForAmount(testBolt11, testAmount))
550+
.thenReturn(Result.success(expectedFeesSats))
551+
552+
val result = sut.estimateRoutingFeesForAmount(testBolt11, testAmount)
553+
554+
assertTrue(result.isSuccess)
555+
assertEquals(expectedFeesSats, result.getOrNull())
556+
verify(lightningService).estimateRoutingFeesForAmount(testBolt11, testAmount)
557+
}
558+
559+
@Test
560+
fun `estimateRoutingFeesForAmount should handle service failure`() = test {
561+
startNodeForTesting()
562+
val testBolt11 = "lnbc1u1p0abcde"
563+
val testAmount = 1000uL
564+
val serviceError = RuntimeException("Service error")
565+
566+
whenever(lightningService.estimateRoutingFeesForAmount(testBolt11, testAmount))
567+
.thenReturn(Result.failure(serviceError))
568+
569+
val result = sut.estimateRoutingFeesForAmount(testBolt11, testAmount)
570+
571+
assertTrue(result.isFailure)
572+
assertEquals(serviceError, result.exceptionOrNull())
573+
}
498574
}

0 commit comments

Comments
 (0)