Skip to content

Commit 9a78080

Browse files
authored
Merge branch 'master' into feat/system-widgets-foundation
2 parents 580246f + 1573ac7 commit 9a78080

15 files changed

Lines changed: 600 additions & 102 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Improve Pubky profile restore, contact editing, and contact routing flows #905
1212

1313
### Fixed
14+
- Fix probe results and add keysend probes #920
15+
- Align top bar back arrow and passphrase input cursor/placeholder with iOS #906
1416
- Polish Terms of Use screen padding to match iOS #903
1517

1618
### Added

app/src/main/java/to/bitkit/models/USat.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ value class USat(val value: ULong) : Comparable<USat> {
1717
/** Saturating addition: caps at ULong.MAX_VALUE if result would overflow. */
1818
operator fun plus(other: USat): ULong =
1919
if (value <= ULong.MAX_VALUE - other.value) value + other.value else ULong.MAX_VALUE
20+
21+
/** Saturating multiplication: caps at ULong.MAX_VALUE if result would overflow. */
22+
operator fun times(other: USat): ULong =
23+
if (other.value == 0uL || value <= ULong.MAX_VALUE / other.value) value * other.value else ULong.MAX_VALUE
2024
}
2125

2226
/**

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

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
2424
import kotlinx.coroutines.flow.asSharedFlow
2525
import kotlinx.coroutines.flow.asStateFlow
2626
import kotlinx.coroutines.flow.distinctUntilChanged
27+
import kotlinx.coroutines.flow.filter
2728
import kotlinx.coroutines.flow.first
2829
import kotlinx.coroutines.flow.map
30+
import kotlinx.coroutines.flow.mapNotNull
31+
import kotlinx.coroutines.flow.onSubscription
2932
import kotlinx.coroutines.flow.update
3033
import kotlinx.coroutines.isActive
3134
import kotlinx.coroutines.launch
@@ -44,6 +47,7 @@ import org.lightningdevkit.ldknode.ClosureReason
4447
import org.lightningdevkit.ldknode.Event
4548
import org.lightningdevkit.ldknode.NodeStatus
4649
import org.lightningdevkit.ldknode.PaymentDetails
50+
import org.lightningdevkit.ldknode.PaymentHash
4751
import org.lightningdevkit.ldknode.PaymentId
4852
import org.lightningdevkit.ldknode.PeerDetails
4953
import org.lightningdevkit.ldknode.SpendableUtxo
@@ -60,6 +64,7 @@ import to.bitkit.ext.nowTimestamp
6064
import to.bitkit.ext.toPeerDetailsList
6165
import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS
6266
import to.bitkit.models.CoinSelectionPreference
67+
import to.bitkit.models.MSat
6368
import to.bitkit.models.NATIVE_WITNESS_TYPES
6469
import to.bitkit.models.NodeLifecycleState
6570
import to.bitkit.models.OpenChannelResult
@@ -121,6 +126,8 @@ class LightningRepo @Inject constructor(
121126
val isRecoveryMode = _isRecoveryMode.asStateFlow()
122127

123128
private val channelCache = ConcurrentHashMap<String, ChannelDetails>()
129+
private val probeOutcomeCache = ConcurrentHashMap<PaymentId, ProbeOutcome>()
130+
private val probeOutcomeSignal = MutableSharedFlow<ProbeOutcome>(extraBufferCapacity = 64)
124131

125132
private val syncMutex = Mutex()
126133
private val syncPending = AtomicBoolean(false)
@@ -420,6 +427,7 @@ class LightningRepo @Inject constructor(
420427

421428
private suspend fun onEvent(event: Event) {
422429
handleLdkEvent(event)
430+
recordProbeOutcome(event)
423431
_eventHandlers.toList().forEach {
424432
runCatching { it.invoke(event) }
425433
}
@@ -441,12 +449,14 @@ class LightningRepo @Inject constructor(
441449
suspend fun stop(): Result<Unit> = withContext(bgDispatcher) {
442450
lifecycleMutex.withLock {
443451
if (_lightningState.value.nodeLifecycleState.isStoppedOrStopping()) {
452+
clearProbeOutcomes()
444453
return@withLock Result.success(Unit)
445454
}
446455

447456
runCatching {
448457
_lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) }
449458
lightningService.stop()
459+
clearProbeOutcomes()
450460
_lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) }
451461
}.onFailure {
452462
Logger.error("Node stop error", it, context = TAG)
@@ -529,6 +539,21 @@ class LightningRepo @Inject constructor(
529539
}
530540
}
531541

542+
private suspend fun recordProbeOutcome(event: Event) {
543+
val outcome = when (event) {
544+
is Event.ProbeSuccessful -> ProbeOutcome.Success(event.paymentId, event.paymentHash)
545+
is Event.ProbeFailed -> ProbeOutcome.Failure(event.paymentId, event.paymentHash, event.shortChannelId)
546+
else -> return
547+
}
548+
549+
probeOutcomeCache[outcome.paymentId] = outcome
550+
probeOutcomeSignal.emit(outcome)
551+
}
552+
553+
private fun clearProbeOutcomes() {
554+
probeOutcomeCache.clear()
555+
}
556+
532557
private suspend fun registerClosedChannel(channelId: String, reason: ClosureReason?) = withContext(bgDispatcher) {
533558
runCatching {
534559
val channel = channelCache[channelId] ?: run {
@@ -582,6 +607,7 @@ class LightningRepo @Inject constructor(
582607
stop().mapCatching {
583608
Logger.debug("node stopped, calling wipeStorage", context = TAG)
584609
lightningService.wipeStorage(walletIndex)
610+
clearProbeOutcomes()
585611
_lightningState.update {
586612
LightningState(
587613
nodeStatus = it.nodeStatus,
@@ -1363,23 +1389,74 @@ class LightningRepo @Inject constructor(
13631389
// endregion
13641390

13651391
// region probing
1366-
suspend fun sendProbeForInvoice(bolt11: String, amountSats: ULong? = null): Result<Unit> =
1392+
suspend fun sendProbeForInvoice(bolt11: String, amountSats: ULong? = null): Result<ProbeDispatch> =
13671393
executeWhenNodeRunning("sendProbeForInvoice") {
13681394
Logger.debug(
1369-
"sendProbeForInvoice: amountSats=${amountSats ?: "null (using invoice amount)"}",
1370-
context = TAG
1395+
"sendProbeForInvoice: amountSats='${amountSats ?: "null (using invoice amount)"}'",
1396+
context = TAG,
13711397
)
1372-
runCatching {
1373-
if (amountSats != null) {
1374-
val amountMsat = amountSats * 1000u
1375-
lightningService.sendProbesUsingAmount(bolt11, amountMsat)
1376-
} else {
1377-
lightningService.sendProbes(bolt11)
1378-
}
1379-
}.getOrElse {
1380-
Result.failure(it)
1398+
val result = if (amountSats != null) {
1399+
val amountMsat = amountSats.safe() * MSat.PER_SAT.safe()
1400+
lightningService.sendProbesUsingAmount(bolt11, amountMsat)
1401+
} else {
1402+
lightningService.sendProbes(bolt11)
13811403
}
1404+
1405+
result.map { ProbeDispatch(paymentIds = it) }
13821406
}
1407+
1408+
suspend fun sendProbeForNode(nodeId: String, amountSats: ULong): Result<ProbeDispatch> =
1409+
executeWhenNodeRunning("sendProbeForNode") {
1410+
Logger.debug(
1411+
"Sending keysend probe to nodeId='$nodeId' amountSats='$amountSats'",
1412+
context = TAG,
1413+
)
1414+
val amountMsat = amountSats.safe() * MSat.PER_SAT.safe()
1415+
lightningService.sendKeysendProbe(nodeId, amountMsat).map {
1416+
ProbeDispatch(paymentIds = it)
1417+
}
1418+
}
1419+
1420+
suspend fun waitForProbeOutcome(
1421+
paymentIds: Set<PaymentId>,
1422+
timeout: Duration = PROBE_TIMEOUT,
1423+
): Result<ProbeOutcome> = withContext(bgDispatcher) {
1424+
if (paymentIds.isEmpty()) {
1425+
return@withContext Result.failure(ProbeError.NoProbeHandles())
1426+
}
1427+
1428+
val trackedIds = paymentIds.toSet()
1429+
val outcome = withTimeoutOrNull(timeout) {
1430+
val pending = trackedIds.toMutableSet()
1431+
var lastFailure: ProbeOutcome.Failure? = null
1432+
1433+
probeOutcomeSignal
1434+
.onSubscription {
1435+
trackedIds.forEach { id ->
1436+
probeOutcomeCache[id]?.let { emit(it) }
1437+
}
1438+
}
1439+
.filter { it.paymentId in trackedIds }
1440+
.mapNotNull { probeOutcome ->
1441+
if (!pending.remove(probeOutcome.paymentId)) return@mapNotNull null
1442+
1443+
probeOutcomeCache.remove(probeOutcome.paymentId)
1444+
when (probeOutcome) {
1445+
is ProbeOutcome.Success -> probeOutcome
1446+
is ProbeOutcome.Failure -> {
1447+
lastFailure = probeOutcome
1448+
if (pending.isEmpty()) lastFailure else null
1449+
}
1450+
}
1451+
}
1452+
.first()
1453+
}
1454+
1455+
trackedIds.forEach { probeOutcomeCache.remove(it) }
1456+
1457+
outcome?.let { Result.success(it) }
1458+
?: Result.failure(ProbeError.TimedOut())
1459+
}
13831460
// endregion
13841461

13851462
suspend fun restartNode(): Result<Unit> = withContext(bgDispatcher) {
@@ -1404,6 +1481,7 @@ class LightningRepo @Inject constructor(
14041481
private const val CHANNELS_READY_TIMEOUT_MS = 15_000L
14051482
private const val CHANNELS_USABLE_TIMEOUT_MS = 15_000L
14061483
val SEND_LN_TIMEOUT = 10.seconds
1484+
private val PROBE_TIMEOUT = 60.seconds
14071485
}
14081486
}
14091487

@@ -1413,6 +1491,10 @@ class NodeStopTimeoutError : AppError("Timeout waiting for node to stop")
14131491
class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'")
14141492
class GetPaymentsError : AppError("It wasn't possible get the payments")
14151493
class SyncUnhealthyError : AppError("Wallet sync failed before send")
1494+
sealed class ProbeError(message: String) : AppError(message) {
1495+
class NoProbeHandles : ProbeError("No probe handles returned")
1496+
class TimedOut : ProbeError("Probe timed out")
1497+
}
14161498

14171499
@Stable
14181500
data class LightningState(
@@ -1436,3 +1518,23 @@ data class LightningState(
14361518
val isSyncHealthy: Boolean
14371519
get() = lastSyncError == null && lastSuccessfulSyncAt != null
14381520
}
1521+
1522+
data class ProbeDispatch(
1523+
val paymentIds: Set<PaymentId>,
1524+
)
1525+
1526+
sealed interface ProbeOutcome {
1527+
val paymentId: PaymentId
1528+
val paymentHash: PaymentHash
1529+
1530+
data class Success(
1531+
override val paymentId: PaymentId,
1532+
override val paymentHash: PaymentHash,
1533+
) : ProbeOutcome
1534+
1535+
data class Failure(
1536+
override val paymentId: PaymentId,
1537+
override val paymentHash: PaymentHash,
1538+
val shortChannelId: ULong?,
1539+
) : ProbeOutcome
1540+
}

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import org.lightningdevkit.ldknode.NodeStatus
3636
import org.lightningdevkit.ldknode.PaymentDetails
3737
import org.lightningdevkit.ldknode.PaymentId
3838
import org.lightningdevkit.ldknode.PeerDetails
39+
import org.lightningdevkit.ldknode.PublicKey
3940
import org.lightningdevkit.ldknode.SpendableUtxo
4041
import org.lightningdevkit.ldknode.Txid
4142
import org.lightningdevkit.ldknode.defaultConfig
@@ -135,6 +136,7 @@ class LightningService @Inject constructor(
135136
trustedPeersNoReserve = trustedPeerNodeIds,
136137
perChannelReserveSats = 1u,
137138
),
139+
probingLiquidityLimitMultiplier = 1uL,
138140
includeUntrustedPendingInSpendable = true,
139141
)
140142
}
@@ -721,7 +723,7 @@ class LightningService @Inject constructor(
721723
// endregion
722724

723725
// region probing
724-
suspend fun sendProbes(bolt11: String): Result<Unit> {
726+
suspend fun sendProbes(bolt11: String): Result<Set<PaymentId>> {
725727
val node = this.node ?: throw ServiceError.NodeNotSetup()
726728

727729
val bolt11Invoice = runCatching { Bolt11Invoice.fromStr(bolt11) }
@@ -735,16 +737,16 @@ class LightningService @Inject constructor(
735737

736738
return ServiceQueue.LDK.background {
737739
runCatching {
738-
node.bolt11Payment().sendProbes(bolt11Invoice, null)
739-
Result.success(Unit)
740+
val handles = node.bolt11Payment().sendProbes(bolt11Invoice, null)
741+
Result.success(handles.map { it.paymentId }.toSet())
740742
}.getOrElse {
741743
dumpNetworkGraphInfo(bolt11)
742744
Result.failure(if (it is NodeException) LdkError(it) else it)
743745
}
744746
}
745747
}
746748

747-
suspend fun sendProbesUsingAmount(bolt11: String, amountMsat: ULong): Result<Unit> {
749+
suspend fun sendProbesUsingAmount(bolt11: String, amountMsat: ULong): Result<Set<PaymentId>> {
748750
val node = this.node ?: throw ServiceError.NodeNotSetup()
749751

750752
val bolt11Invoice = runCatching { Bolt11Invoice.fromStr(bolt11) }
@@ -759,14 +761,32 @@ class LightningService @Inject constructor(
759761

760762
return ServiceQueue.LDK.background {
761763
runCatching {
762-
node.bolt11Payment().sendProbesUsingAmount(bolt11Invoice, amountMsat, null)
763-
Result.success(Unit)
764+
val handles = node.bolt11Payment().sendProbesUsingAmount(bolt11Invoice, amountMsat, null)
765+
Result.success(handles.map { it.paymentId }.toSet())
764766
}.getOrElse {
765767
dumpNetworkGraphInfo(bolt11)
766768
Result.failure(if (it is NodeException) LdkError(it) else it)
767769
}
768770
}
769771
}
772+
773+
suspend fun sendKeysendProbe(nodeId: PublicKey, amountMsat: ULong): Result<Set<PaymentId>> {
774+
val node = this.node ?: throw ServiceError.NodeNotSetup()
775+
776+
Logger.debug(
777+
"Sending keysend probe to nodeId='$nodeId' amountMsat='$amountMsat' (${msatFloorOf(amountMsat)} sats)",
778+
context = TAG,
779+
)
780+
781+
return ServiceQueue.LDK.background {
782+
runCatching {
783+
val handles = node.spontaneousPayment().sendProbes(amountMsat, nodeId)
784+
Result.success(handles.map { it.paymentId }.toSet())
785+
}.getOrElse {
786+
Result.failure(if (it is NodeException) LdkError(it) else it)
787+
}
788+
}
789+
}
770790
// endregion
771791

772792
// region utxo selection

app/src/main/java/to/bitkit/ui/onboarding/CreateWalletWithPassphraseScreen.kt

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,8 @@ import androidx.compose.foundation.layout.height
1010
import androidx.compose.foundation.layout.imePadding
1111
import androidx.compose.foundation.layout.padding
1212
import androidx.compose.foundation.rememberScrollState
13-
import androidx.compose.foundation.shape.RoundedCornerShape
1413
import androidx.compose.foundation.text.KeyboardOptions
1514
import androidx.compose.foundation.verticalScroll
16-
import androidx.compose.material3.OutlinedTextField
17-
import androidx.compose.material3.Text
1815
import androidx.compose.runtime.Composable
1916
import androidx.compose.runtime.getValue
2017
import androidx.compose.runtime.mutableStateOf
@@ -35,13 +32,13 @@ import to.bitkit.ui.components.BodyM
3532
import to.bitkit.ui.components.Display
3633
import to.bitkit.ui.components.HighlightLabel
3734
import to.bitkit.ui.components.PrimaryButton
35+
import to.bitkit.ui.components.TextInput
3836
import to.bitkit.ui.components.TopBarSpacer
3937
import to.bitkit.ui.components.VerticalSpacer
4038
import to.bitkit.ui.components.mainRectHeight
4139
import to.bitkit.ui.scaffold.AppTopBar
4240
import to.bitkit.ui.shared.effects.BlockScreenshots
4341
import to.bitkit.ui.shared.util.screen
44-
import to.bitkit.ui.theme.AppTextFieldDefaults
4542
import to.bitkit.ui.theme.AppThemeSurface
4643
import to.bitkit.ui.theme.Colors
4744
import to.bitkit.ui.theme.TopBarHeight
@@ -98,12 +95,10 @@ fun CreateWalletWithPassphraseScreen(
9895
color = Colors.White64,
9996
)
10097
Spacer(modifier = Modifier.height(32.dp))
101-
OutlinedTextField(
98+
TextInput(
10299
value = bip39Passphrase,
103100
onValueChange = { bip39Passphrase = it },
104-
placeholder = { Text(text = stringResource(R.string.onboarding__passphrase)) },
105-
shape = RoundedCornerShape(8.dp),
106-
colors = AppTextFieldDefaults.semiTransparent,
101+
placeholder = stringResource(R.string.onboarding__passphrase),
107102
singleLine = true,
108103
keyboardOptions = KeyboardOptions(
109104
autoCorrectEnabled = false,

app/src/main/java/to/bitkit/ui/scaffold/AppTopBar.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import androidx.compose.foundation.layout.Row
66
import androidx.compose.foundation.layout.RowScope
77
import androidx.compose.foundation.layout.padding
88
import androidx.compose.foundation.layout.size
9-
import androidx.compose.material.icons.Icons
10-
import androidx.compose.material.icons.automirrored.filled.ArrowBack
119
import androidx.compose.material3.CenterAlignedTopAppBar
1210
import androidx.compose.material3.ExperimentalMaterial3Api
1311
import androidx.compose.material3.Icon
@@ -39,7 +37,7 @@ fun AppTopBar(
3937
onBackClick: (() -> Unit)?,
4038
modifier: Modifier = Modifier,
4139
@DrawableRes icon: Int? = null,
42-
actions: @Composable (RowScope.() -> Unit) = {}
40+
actions: @Composable (RowScope.() -> Unit) = {},
4341
) {
4442
CenterAlignedTopAppBar(
4543
navigationIcon = {
@@ -84,7 +82,7 @@ fun BackNavIcon(
8482
modifier = modifier.testTag("NavigationBack")
8583
) {
8684
Icon(
87-
imageVector = Icons.AutoMirrored.Default.ArrowBack,
85+
painter = painterResource(R.drawable.ic_arrow_left),
8886
contentDescription = stringResource(R.string.common__back),
8987
modifier = Modifier.size(24.dp)
9088
)

0 commit comments

Comments
 (0)