Skip to content

Commit 93fd5ab

Browse files
authored
Merge branch 'master' into feat/mainnet-probe-devtools
2 parents 7278bac + 6fddaf5 commit 93fd5ab

36 files changed

Lines changed: 1381 additions & 627 deletions

.github/workflows/e2e.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ jobs:
305305
matrix:
306306
shard:
307307
- { name: multi_address_2_regtest, grep: "@multi_address_2" }
308+
- { name: pubky_paykit, grep: "@pubky" }
308309

309310
name: e2e-tests-staging - ${{ matrix.shard.name }}
310311

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
9+
- Fix Spending and Savings screens scrolling behind top bar and add gradient fade effect #892
910

1011
### Changed
1112
- Improve Pubky profile restore, contact editing, and contact routing flows #905
@@ -25,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2526
- Show loading state on Spending tab when node is not running #875
2627

2728
### Added
29+
- Transfer from Savings button on empty Spending screen when savings balance exists #882
2830
- Pubky profile onboarding with contact sync, import, and editing #824
2931
- Lightning Connections empty state with onboarding screen #857
3032
- Unified PIN management screen (enable/disable/change in one place) #857

app/src/main/java/to/bitkit/env/Env.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ internal object Env {
246246
@Suppress("ConstPropertyName")
247247
object Defaults {
248248
/** Default Bolt11 invoice expiry in seconds. */
249-
const val bolt11InvoiceExpirySeconds = 3_600u
249+
const val bolt11ExpirySec = 86_400u
250250

251251
/** Recommended transaction base fee in sats */
252252
const val recommendedBaseFee = 256u

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

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,17 @@ import kotlinx.collections.immutable.persistentListOf
2020
import kotlinx.collections.immutable.toImmutableList
2121
import kotlinx.coroutines.CoroutineDispatcher
2222
import kotlinx.coroutines.CoroutineScope
23+
import kotlinx.coroutines.CoroutineStart
2324
import kotlinx.coroutines.SupervisorJob
25+
import kotlinx.coroutines.async
26+
import kotlinx.coroutines.coroutineScope
2427
import kotlinx.coroutines.currentCoroutineContext
2528
import kotlinx.coroutines.delay
2629
import kotlinx.coroutines.flow.MutableStateFlow
2730
import kotlinx.coroutines.flow.StateFlow
2831
import kotlinx.coroutines.flow.asStateFlow
2932
import kotlinx.coroutines.flow.distinctUntilChanged
33+
import kotlinx.coroutines.flow.filterIsInstance
3034
import kotlinx.coroutines.flow.first
3135
import kotlinx.coroutines.flow.flow
3236
import kotlinx.coroutines.flow.flowOn
@@ -37,16 +41,19 @@ import kotlinx.coroutines.flow.update
3741
import kotlinx.coroutines.isActive
3842
import kotlinx.coroutines.launch
3943
import kotlinx.coroutines.withContext
44+
import kotlinx.coroutines.withTimeoutOrNull
45+
import org.lightningdevkit.ldknode.Bolt11Invoice
4046
import org.lightningdevkit.ldknode.ChannelDetails
47+
import org.lightningdevkit.ldknode.Event
4148
import to.bitkit.async.ServiceQueue
4249
import to.bitkit.data.CacheStore
4350
import to.bitkit.di.BgDispatcher
44-
import to.bitkit.env.Defaults
4551
import to.bitkit.env.Env
4652
import to.bitkit.ext.calculateRemoteBalance
4753
import to.bitkit.ext.nowTimestamp
4854
import to.bitkit.models.BlocktankBackupV1
4955
import to.bitkit.models.EUR
56+
import to.bitkit.models.msatCeilOf
5057
import to.bitkit.services.CoreService
5158
import to.bitkit.services.LightningService
5259
import to.bitkit.utils.Logger
@@ -57,6 +64,7 @@ import javax.inject.Named
5764
import javax.inject.Singleton
5865
import kotlin.math.ceil
5966
import kotlin.time.Duration
67+
import kotlin.time.Duration.Companion.hours
6068
import kotlin.time.Duration.Companion.seconds
6169

6270
@Singleton
@@ -459,26 +467,52 @@ class BlocktankRepo @Inject constructor(
459467
}
460468
}
461469

462-
private suspend fun claimGiftCodeWithLiquidity(code: String, amount: ULong): GiftClaimResult {
470+
private suspend fun claimGiftCodeWithLiquidity(code: String, amount: ULong): GiftClaimResult = coroutineScope {
463471
val invoice = lightningRepo.createInvoice(
464472
amountSats = null,
465473
description = "blocktank-gift-code:$code",
466-
expirySeconds = Defaults.bolt11InvoiceExpirySeconds,
474+
expirySeconds = 1.hours.inWholeSeconds.toUInt(),
467475
).getOrThrow()
468476

477+
val expectedPaymentHash = Bolt11Invoice.fromStr(invoice).paymentHash()
478+
469479
Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG)
470480

481+
val paymentReceivedDeferred = async(start = CoroutineStart.UNDISPATCHED) {
482+
lightningRepo.nodeEvents
483+
.filterIsInstance<Event.PaymentReceived>()
484+
.first { it.paymentHash == expectedPaymentHash }
485+
}
486+
471487
val giftResponse = ServiceQueue.CORE.background {
472488
giftPay(invoice = invoice)
473489
}
474490

475-
Logger.debug("Gift payment request completed: id=${giftResponse.id}", context = TAG)
491+
Logger.debug(
492+
"Gift payment request completed: id='${giftResponse.id}', awaiting LDK PaymentReceived",
493+
context = TAG,
494+
)
495+
496+
val paymentReceived = withTimeoutOrNull(GIFT_PAYMENT_RECEIVE_TIMEOUT) {
497+
paymentReceivedDeferred.await()
498+
}
499+
500+
if (paymentReceived == null) {
501+
paymentReceivedDeferred.cancel()
502+
throw ServiceError.GiftClaimPaymentNotReceived()
503+
}
504+
505+
Logger.debug(
506+
"Gift payment confirmed by LDK: hash='${paymentReceived.paymentHash}', " +
507+
"amountMsat='${paymentReceived.amountMsat}'",
508+
context = TAG,
509+
)
510+
511+
val receivedSats = msatCeilOf(paymentReceived.amountMsat).toLong()
476512

477-
return GiftClaimResult.SuccessWithLiquidity(
478-
paymentHashOrTxId = giftResponse.bolt11PaymentId ?: giftResponse.id,
479-
sats = giftResponse.bolt11Payment?.paidSat?.toLong()
480-
?: giftResponse.appliedGiftCode?.giftSat?.toLong()
481-
?: amount.toLong(),
513+
GiftClaimResult.SuccessWithLiquidity(
514+
paymentHashOrTxId = paymentReceived.paymentHash,
515+
sats = receivedSats.takeIf { it > 0 } ?: amount.toLong(),
482516
invoice = invoice,
483517
code = code,
484518
)
@@ -518,6 +552,7 @@ class BlocktankRepo @Inject constructor(
518552
private const val DEFAULT_SOURCE = "bitkit-android"
519553
private const val PEER_CONNECTION_DELAY_MS = 2_000L
520554
private val TIMEOUT_GIFT_CODE = 30.seconds
555+
private val GIFT_PAYMENT_RECEIVE_TIMEOUT = 45.seconds
521556
}
522557
}
523558

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

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,12 @@ import to.bitkit.data.SettingsStore
5858
import to.bitkit.data.backup.VssBackupClientLdk
5959
import to.bitkit.data.keychain.Keychain
6060
import to.bitkit.di.BgDispatcher
61+
import to.bitkit.env.Defaults
6162
import to.bitkit.env.Env
6263
import to.bitkit.ext.getSatsPerVByteFor
6364
import to.bitkit.ext.nowTimestamp
6465
import to.bitkit.ext.toPeerDetailsList
66+
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
6567
import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS
6668
import to.bitkit.models.CoinSelectionPreference
6769
import to.bitkit.models.NATIVE_WITNESS_TYPES
@@ -93,6 +95,7 @@ import javax.inject.Inject
9395
import javax.inject.Singleton
9496
import kotlin.coroutines.cancellation.CancellationException
9597
import kotlin.time.Duration
98+
import kotlin.time.Duration.Companion.milliseconds
9699
import kotlin.time.Duration.Companion.minutes
97100
import kotlin.time.Duration.Companion.seconds
98101

@@ -151,6 +154,10 @@ class LightningRepo @Inject constructor(
151154
return@collect
152155
}
153156

157+
if (_lightningState.value.nodeLifecycleState.isRunning()) {
158+
connectToTrustedPeers()
159+
}
160+
154161
// Start retry loop if sync is failing
155162
startSyncRetryLoopIfNeeded()
156163
}
@@ -533,7 +540,11 @@ class LightningRepo @Inject constructor(
533540

534541
private fun handleLdkEvent(event: Event) {
535542
when (event) {
536-
is Event.ChannelPending, is Event.ChannelReady -> scope.launch { refreshChannelCache() }
543+
is Event.ChannelPending, is Event.ChannelReady -> scope.launch {
544+
refreshChannelCache()
545+
syncState()
546+
}
547+
537548
is Event.ChannelClosed -> scope.launch { registerClosedChannel(event.channelId, event.reason) }
538549
else -> Unit
539550
}
@@ -917,7 +928,7 @@ class LightningRepo @Inject constructor(
917928
suspend fun createInvoice(
918929
amountSats: ULong? = null,
919930
description: String,
920-
expirySeconds: UInt = 86_400u,
931+
expirySeconds: UInt = Defaults.bolt11ExpirySec,
921932
): Result<String> = executeWhenNodeRunning("createInvoice") {
922933
updateGeoBlockState()
923934
runCatching { lightningService.receive(amountSats, description, expirySeconds) }
@@ -926,7 +937,7 @@ class LightningRepo @Inject constructor(
926937
suspend fun createInvoiceMsats(
927938
amountMsats: ULong,
928939
description: String,
929-
expirySeconds: UInt = 86_400u,
940+
expirySeconds: UInt = Defaults.bolt11ExpirySec,
930941
): Result<String> = executeWhenNodeRunning("createInvoiceMsats") {
931942
updateGeoBlockState()
932943
runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) }
@@ -1009,15 +1020,69 @@ class LightningRepo @Inject constructor(
10091020
}
10101021
}
10111022

1012-
private suspend fun waitForUsableChannels() {
1013-
if (lightningService.channels?.any { it.isUsable } == true) return
1023+
suspend fun waitForUsableChannels() = withContext(bgDispatcher) {
1024+
var state = _lightningState.value
1025+
if (!state.nodeLifecycleState.canRun()) {
1026+
delayNoUsableChannelsFeedback()
1027+
return@withContext
1028+
}
1029+
if (state.hasUsableChannels()) return@withContext
1030+
1031+
state = waitForChannelsToLoadIfNeeded(state) ?: return@withContext
1032+
if (!state.nodeLifecycleState.canRun()) {
1033+
delayNoUsableChannelsFeedback()
1034+
return@withContext
1035+
}
1036+
1037+
if (state.channels.isEmpty()) {
1038+
if (state.nodeLifecycleState.isRunning()) {
1039+
syncState()
1040+
state = _lightningState.value
1041+
}
1042+
1043+
if (state.channels.isEmpty()) {
1044+
delayNoUsableChannelsFeedback()
1045+
return@withContext
1046+
}
1047+
if (state.hasUsableChannels()) return@withContext
1048+
}
10141049

10151050
Logger.info("Waiting for usable channels before sending payment", context = TAG)
1016-
syncState()
10171051

1018-
withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT_MS) {
1019-
_lightningState.first { state -> state.channels.any { it.isUsable } }
1020-
} ?: Logger.warn("Timeout waiting for usable channels", context = TAG)
1052+
val finalState = withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT) {
1053+
_lightningState.first { it.shouldStopWaitingForUsableChannels() }
1054+
} ?: run {
1055+
Logger.warn("Timed out waiting for usable channels", context = TAG)
1056+
return@withContext
1057+
}
1058+
1059+
if (!finalState.nodeLifecycleState.canRun() || finalState.channels.isEmpty()) {
1060+
delayNoUsableChannelsFeedback()
1061+
}
1062+
}
1063+
1064+
private suspend fun waitForChannelsToLoadIfNeeded(state: LightningState): LightningState? {
1065+
if (state.channels.isNotEmpty() || state.nodeLifecycleState.isRunning()) return state
1066+
1067+
Logger.info("Waiting for node to load channels before sending payment", context = TAG)
1068+
return withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT) {
1069+
_lightningState.first { it.shouldStopWaitingForLoadedChannels() }
1070+
} ?: run {
1071+
Logger.warn("Timed out waiting for node to load channels", context = TAG)
1072+
null
1073+
}
1074+
}
1075+
1076+
private fun LightningState.hasUsableChannels() = channels.any { it.isUsable }
1077+
1078+
private fun LightningState.shouldStopWaitingForLoadedChannels() =
1079+
!nodeLifecycleState.canRun() || nodeLifecycleState.isRunning() || channels.isNotEmpty()
1080+
1081+
private fun LightningState.shouldStopWaitingForUsableChannels() =
1082+
!nodeLifecycleState.canRun() || channels.isEmpty() || hasUsableChannels()
1083+
1084+
private suspend fun delayNoUsableChannelsFeedback() {
1085+
delay(NO_USABLE_CHANNELS_FEEDBACK_DELAY)
10211086
}
10221087

10231088
@Suppress("LongParameterList")
@@ -1229,19 +1294,20 @@ class LightningRepo @Inject constructor(
12291294
}
12301295
}
12311296

1232-
suspend fun canSend(amountSats: ULong, fallbackToCachedBalance: Boolean = true) = withContext(bgDispatcher) {
1233-
if (!_lightningState.value.nodeLifecycleState.canRun()) {
1234-
return@withContext false
1235-
}
1236-
if (_lightningState.value.nodeLifecycleState.isStarting() && fallbackToCachedBalance) {
1237-
return@withContext amountSats <= (cacheStore.data.first().balance?.maxSendLightningSats ?: 0u)
1238-
}
1239-
if (lightningService.channels == null) {
1240-
withTimeoutOrNull(CHANNELS_READY_TIMEOUT_MS) {
1241-
_lightningState.first { lightningService.channels != null }
1297+
suspend fun awaitPeerConnected(timeout: Duration = 30.seconds) = withContext(bgDispatcher) {
1298+
if (lightningService.peers?.any { it.isConnected } == true) return@withContext
1299+
Logger.debug("Waiting for peer to reconnect (timeout='$timeout')...", context = TAG)
1300+
withTimeoutOrNull(timeout) {
1301+
while (lightningService.peers?.any { it.isConnected } != true) {
1302+
delay(1.seconds)
12421303
}
12431304
}
1244-
return@withContext lightningService.canSend(amountSats)
1305+
}
1306+
1307+
fun canSend(amountSats: ULong): Boolean {
1308+
val state = _lightningState.value
1309+
if (!state.nodeLifecycleState.canRun()) return false
1310+
return state.channels.totalNextOutboundHtlcLimitSats() >= amountSats
12451311
}
12461312

12471313
fun getNodeId(): String? =
@@ -1478,8 +1544,8 @@ class LightningRepo @Inject constructor(
14781544
private const val LENGTH_CHANNEL_ID_PREVIEW = 10
14791545
private const val MS_SYNC_LOOP_DEBOUNCE = 500L
14801546
private const val SYNC_RETRY_DELAY_MS = 15_000L
1481-
private const val CHANNELS_READY_TIMEOUT_MS = 15_000L
1482-
private const val CHANNELS_USABLE_TIMEOUT_MS = 15_000L
1547+
private val CHANNELS_USABLE_TIMEOUT = 15.seconds
1548+
private val NO_USABLE_CHANNELS_FEEDBACK_DELAY = 2_500.milliseconds
14831549
val SEND_LN_TIMEOUT = 10.seconds
14841550
private val PROBE_TIMEOUT = 60.seconds
14851551
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import org.lightningdevkit.ldknode.TransactionDetails
7070
import to.bitkit.async.ServiceQueue
7171
import to.bitkit.data.CacheStore
7272
import to.bitkit.data.SettingsStore
73+
import to.bitkit.env.Defaults
7374
import to.bitkit.env.Env
7475
import to.bitkit.ext.amountSats
7576
import to.bitkit.ext.channelId
@@ -1523,11 +1524,15 @@ class BlocktankService(
15231524
)
15241525
}
15251526

1526-
suspend fun regtestCloseChannel(fundingTxId: String, vout: UInt, forceCloseAfterS: ULong = 86_400uL): String {
1527+
suspend fun regtestCloseChannel(
1528+
fundingTxId: String,
1529+
vout: UInt,
1530+
forceCloseAfterS: UInt = Defaults.bolt11ExpirySec,
1531+
): String {
15271532
return com.synonym.bitkitcore.regtestCloseChannel(
15281533
fundingTxId = fundingTxId,
15291534
vout = vout,
1530-
forceCloseAfterS = forceCloseAfterS,
1535+
forceCloseAfterS = forceCloseAfterS.toULong(),
15311536
)
15321537
}
15331538
}

0 commit comments

Comments
 (0)