From 121e74f0edd7c479e92af15141a0a723f5af2080 Mon Sep 17 00:00:00 2001 From: ovitrif Date: Wed, 28 Oct 2026 16:38:36 +0100 Subject: [PATCH 01/10] feat: lightning-first primary node --- app/build.gradle.kts | 4 +- app/src/main/java/to/bitkit/env/Env.kt | 13 +++++ .../bitkit/models/ReceiveLiquidityDecision.kt | 4 ++ .../to/bitkit/repositories/LightningRepo.kt | 16 ++++++ .../java/to/bitkit/repositories/WalletRepo.kt | 13 +++-- .../to/bitkit/services/LightningService.kt | 51 +++++++++++++++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 11 +++- .../external/ExternalNodeViewModel.kt | 54 ++++++++++++++++++- .../bitkit/ui/screens/wallets/HomeScreen.kt | 3 +- .../screens/wallets/receive/EditInvoiceVM.kt | 2 + .../wallets/receive/ReceiveQrScreen.kt | 2 + .../screens/wallets/receive/ReceiveSheet.kt | 21 +++++--- .../models/ReceiveLiquidityDecisionTest.kt | 31 +++++++++++ .../bitkit/services/LightningServiceTest.kt | 1 + gradle/libs.versions.toml | 4 +- settings.gradle.kts | 6 +++ 16 files changed, 218 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6198983ef9..63a1ad1940 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -556,7 +556,8 @@ dependencies { // Crypto implementation(libs.bouncycastle.provider.jdk) testImplementation(libs.bouncycastle.pkix.jdk) // test certificates - implementation(libs.ldk.node.android) { exclude(group = "net.java.dev.jna", module = "jna") } + implementation(libs.ldk.node.android) { artifact { type = "aar" } } + implementation(libs.atomicfu) // ldk-node-android runtime dependency; the release asset carries no POM val localBitkitCoreAar = providers.gradleProperty("bitkitCoreAar").orNull if (localBitkitCoreAar != null) { debugImplementation(files(localBitkitCoreAar)) @@ -568,7 +569,6 @@ dependencies { implementation(libs.paykit) implementation(libs.vss.client) if (localBitkitCoreAar == null) nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact()) - nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.paykit.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.vss.client.nativeDebugSymbolsArtifact()) // Firebase diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 83a1ed564f..becba6e25b 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -4,9 +4,11 @@ import android.os.Build import com.synonym.bitkitcore.UsdtDestination import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Network +import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.BuildConfig import to.bitkit.BuildConfig.VERSION_NAME import to.bitkit.ext.ensureDir +import to.bitkit.ext.of import to.bitkit.utils.Logger import java.io.File import kotlin.io.path.Path @@ -39,6 +41,12 @@ internal object Env { const val e2eHomegateUrl = BuildConfig.E2E_HOMEGATE_URL val e2eHomeserverPubky = BuildConfig.E2E_HOMESERVER_PUBKY.takeIf { isLocalE2eBackend && it.isNotBlank() } val network = Network.valueOf(BuildConfig.NETWORK) + + /** The Lightning-first primary node: it funds channels just in time and grows them by splicing. */ + val primaryNode: PeerDetails? get() = when (network) { + Network.BITCOIN -> PeerDetails.of(PRIMARY_NODE_URI_MAINNET) + else -> null + } val locales = BuildConfig.LOCALES.split(",") const val walletSyncIntervalSecs = 10_uL const val walletSyncTimeoutSecs = 10_uL @@ -164,6 +172,11 @@ internal object Env { const val BITKIT_TELEGRAM = "https://t.me/bitkitchat" const val BITKIT_GITHUB = "https://github.com/synonymdev" const val BITKIT_HELP_CENTER = "https://help.bitkit.to" + + /** Beignet primary run for the Lightning-first mainnet test, reached through a public TCP relay. */ + const val PRIMARY_NODE_URI_MAINNET = + "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c@bore.pub:39101" + const val BLOCKTANK_SUNSET_URL = "https://blog.bitkit.to/" const val TERMS_OF_USE_URL = "https://bitkit.to/terms-of-use" const val PRIVACY_POLICY_URL = "https://bitkit.to/privacy-policy" diff --git a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt index cf30c1d05b..c1e08fbec9 100644 --- a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt +++ b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt @@ -17,6 +17,7 @@ data class ReceiveAdditionalLiquidityParams( val source: ReceiveLiquiditySource, val invoiceAmountSats: ULong, val inboundCapacitySats: ULong?, + val hasPrimaryNode: Boolean = false, ) object ReceiveLiquidityDecision { @@ -24,7 +25,9 @@ object ReceiveLiquidityDecision { hasReadyChannels: Boolean, inboundCapacitySats: ULong?, invoiceAmountSats: ULong?, + hasPrimaryNode: Boolean = false, ): Boolean { + if (hasPrimaryNode && (invoiceAmountSats ?: 0uL) > 0uL) return true if (!hasReadyChannels || inboundCapacitySats == null) return false if (invoiceAmountSats == null || invoiceAmountSats == 0uL) { @@ -37,6 +40,7 @@ object ReceiveLiquidityDecision { fun additionalLiquidityAction(params: ReceiveAdditionalLiquidityParams): ReceiveAdditionalLiquidityAction { return when { params.source != ReceiveLiquiditySource.SPENDING -> ReceiveAdditionalLiquidityAction.None + params.hasPrimaryNode -> ReceiveAdditionalLiquidityAction.None !needsInboundLiquidity(params.invoiceAmountSats, params.inboundCapacitySats) -> ReceiveAdditionalLiquidityAction.None (params.inboundCapacitySats ?: 0uL) == 0uL -> ReceiveAdditionalLiquidityAction.None diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 4f25822652..76757d1444 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1300,6 +1300,13 @@ class LightningRepo @Inject constructor( runCatching { lightningService.receive(amountSats, description, expirySeconds) } } + suspend fun createPrimaryInvoice( + amountSats: ULong, + description: String, + ): Result = executeWhenNodeRunning("createPrimaryInvoice") { + runSuspendCatching { lightningService.receiveViaPrimary(amountSats, description) } + } + suspend fun createInvoiceMsats( amountMsats: ULong, description: String, @@ -2090,6 +2097,15 @@ class LightningRepo @Inject constructor( } } + suspend fun spliceIn( + channel: ChannelDetails, + amountSats: ULong, + ): Result = executeWhenNodeRunning("spliceIn") { + lightningService.spliceIn(channel, amountSats).also { + syncState() + } + } + suspend fun closeChannel( channel: ChannelDetails, force: Boolean = false, diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index c5e6d14f6e..8fa7307427 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -945,10 +945,15 @@ class WalletRepo @Inject constructor( emptyList() } - val bolt11 = if (canCreateLightningInvoice(amountSats)) { - lightningRepo.createInvoice(amountSats, description).getOrThrow() - } else { - "" + val bolt11 = when { + canCreateLightningInvoice(amountSats) -> + lightningRepo.createInvoice(amountSats, description).getOrThrow() + amountSats != null && Env.primaryNode != null -> + lightningRepo.createPrimaryInvoice(amountSats, description).getOrElse { + Logger.warn("Failed to create primary invoice for '$amountSats' sats", it, context = TAG) + "" + } + else -> "" } setBolt11Locked(bolt11, amountSats, description) val newBip21Url = _walletState.value.bip21 diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index c0ff8e83b1..dd59f880c1 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -206,6 +206,9 @@ class LightningService internal constructor( private const val RELEASE_AMOUNT_SATS = 1_000uL private const val NODE_ID_PREVIEW_LEN = 20 + /** Primary nodes authorize a just-in-time receive for ten minutes at most. */ + private const val PRIMARY_INVOICE_EXPIRY_SECS = 600u + /** LDK's defaults for route parameters, kept when only the routing fee is capped. */ private const val DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA = 1008u private const val DEFAULT_MAX_PATH_COUNT: UByte = 10u @@ -342,6 +345,7 @@ class LightningService internal constructor( setScoringFeeParams(scorerFeeParameters()) setAddressType(selectedType) setAddressTypesToMonitor(monitoredTypes) + Env.primaryNode?.let { setPrimaryNode(it.nodeId) } if (channelMigration != null) { setChannelDataMigration(channelMigration) @@ -429,6 +433,7 @@ class LightningService internal constructor( } reconcileWatchOnlyAccountsBestEffort() + Env.primaryNode?.let { connectPeer(it) } // start event listener after node started onEvent?.let { eventHandler -> @@ -749,7 +754,7 @@ class LightningService internal constructor( ServiceQueue.LDK.background(ldkQueue) { val balances = node.listBalances() val idlePeerIds = idlePeerIds( - peers = node.listPeers(), + peers = node.listPeers().filter { it.nodeId != Env.primaryNode?.nodeId }, channels = node.listChannels(), lightningBalances = balances.lightningBalances, pendingSweeps = balances.pendingBalancesFromChannelClosures, @@ -800,6 +805,20 @@ class LightningService internal constructor( // endregion // region channels + suspend fun spliceIn(channel: ChannelDetails, amountSats: ULong): Result { + val node = this.node ?: throw ServiceError.NodeNotSetup() + + return ServiceQueue.LDK.background(ldkQueue) { + try { + Logger.debug("Splicing in '$amountSats' sats to channel '${channel.channelId}'", context = TAG) + node.spliceIn(channel.userChannelId, channel.counterpartyNodeId, amountSats) + Result.success(Unit) + } catch (e: NodeException) { + Result.failure(LdkError(e)) + } + } + } + suspend fun openChannel( peer: PeerDetails, channelAmountSats: ULong, @@ -942,6 +961,36 @@ class LightningService internal constructor( } } + /** + * Creates an invoice the primary node delivers through a channel it opens or grows just in time, + * accepting at most the fee it quotes now. + */ + suspend fun receiveViaPrimary(sat: ULong, description: String): String { + val node = this.node ?: throw ServiceError.NodeNotSetup() + val primary = requireNotNull(Env.primaryNode) { "No primary node for '${Env.network}'" } + val amountMsat = sat * 1000u + + return ServiceQueue.LDK.background(ldkQueue) { + try { + if (node.listPeers().none { it.nodeId == primary.nodeId && it.isConnected }) { + node.connect(primary.nodeId, primary.address, persist = true) + } + val quote = node.quotePrimaryReceive(primary.nodeId, amountMsat, targetInboundSats = 0u) + Logger.info("Received primary quote '$quote'", context = TAG) + node.receiveViaPrimary( + nodeId = primary.nodeId, + amountMsat = amountMsat, + description = Bolt11InvoiceDescription.Direct(description = description), + expirySecs = PRIMARY_INVOICE_EXPIRY_SECS, + maxFeeMsat = quote.feeMsat, + targetInboundSats = 0u, + ).toString() + } catch (e: NodeException) { + throw LdkError(e) + } + } + } + /** Prepare without dispatch; the caller must durably retain the receipt before calling broadcast. */ suspend fun prepareOnchainSend( address: Address, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 60bd476193..db89ef0f29 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -63,6 +63,7 @@ import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.appWidgetRefreshScheduler import to.bitkit.env.Env import to.bitkit.ext.rawId +import to.bitkit.ext.uri import to.bitkit.ext.walletId import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast @@ -1194,7 +1195,7 @@ private fun NavGraphBuilder.home( onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, - onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, + onTransferToSpendingClick = { navController.navigateToTransferToSpending(appViewModel) }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, ) @@ -1217,7 +1218,7 @@ private fun NavGraphBuilder.home( navController.navigateToTransferSavingsAvailability() } }, - onTransferFromSavingsClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, + onTransferFromSavingsClick = { navController.navigateToTransferToSpending(appViewModel) }, onBackClick = { navController.popBackStack() }, ) } @@ -2076,6 +2077,12 @@ fun NavController.navigateToDevSettings() = navigateTo(Routes.DevSettings) fun NavController.navigateToTransferSavingsIntro() = navigateTo(Routes.SavingsIntro) +/** Opens a channel with the primary node, or explains that transfers need manual setup without one. */ +fun NavController.navigateToTransferToSpending(appViewModel: AppViewModel) { + val primary = Env.primaryNode ?: return appViewModel.showSheet(Sheet.TransferUnavailable) + navigateTo(ExternalConnection(scannedNodeUri = primary.uri)) +} + fun NavController.navigateToTransferSavingsAvailability() = navigateTo(Routes.SavingsAvailability) /** diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index 9b5ea61897..e516ef8ecb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.UserChannelId @@ -28,6 +29,7 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.UiState import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -39,6 +41,10 @@ class ExternalNodeViewModel @Inject constructor( private val settingsStore: SettingsStore, private val transferRepo: to.bitkit.repositories.TransferRepo, ) : ViewModel() { + companion object { + private const val TAG = "ExternalNodeViewModel" + } + private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() @@ -135,10 +141,18 @@ class ExternalNodeViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } + val peer = requireNotNull(_uiState.value.peer) + val readyChannel = lightningRepo.getChannels() + ?.firstOrNull { it.counterpartyNodeId == peer.nodeId && it.isChannelReady } + if (readyChannel != null) { + spliceIn(readyChannel) + return@launch + } + @Suppress("ForbiddenComment") // TODO: pass customFeeRate to ldk-node when supported lightningRepo.openChannel( - peer = requireNotNull(_uiState.value.peer), + peer = peer, channelAmountSats = _uiState.value.amount.sats.toULong(), ).mapCatching { result -> awaitChannelPendingEvent(result.userChannelId).mapCatching { event -> @@ -168,6 +182,44 @@ class ExternalNodeViewModel @Inject constructor( } } + private suspend fun spliceIn(channel: ChannelDetails) { + val amountSats = _uiState.value.amount.sats.toULong() + lightningRepo.spliceIn(channel, amountSats).mapCatching { + val event = awaitSplicePendingEvent(channel.userChannelId).getOrThrow() + transferRepo.createTransfer( + type = TransferType.MANUAL_SETUP, + amountSats = amountSats.toLong(), + channelId = event.channelId, + fundingTxId = event.newFundingTxo.txid, + ) + }.onSuccess { + setEffect(SideEffect.ConfirmSuccess) + }.onFailure { + Logger.warn("Failed to splice in to channel '${channel.channelId}'", it, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.lightning__error_channel_purchase), + description = context.getString(R.string.lightning__error_channel_setup_msg) + .replace("{raw}", it.message.orEmpty()), + ) + } + _uiState.update { it.copy(isLoading = false) } + } + + private suspend fun awaitSplicePendingEvent(userChannelId: UserChannelId): Result { + return lightningRepo.nodeEvents.watchUntil { event -> + when (event) { + is Event.SpliceFailed if event.userChannelId == userChannelId -> + WatchResult.Complete(Result.failure(AppError("Splice failed for '$userChannelId'"))) + is Event.SplicePending if event.userChannelId == userChannelId -> + WatchResult.Complete(Result.success(event)) + else -> WatchResult.Continue() + } + }.let { + checkNotNull(it) { "Timeout in awaitSplicePendingEvent for userChannelId='$userChannelId'" } + } + } + private suspend fun awaitChannelPendingEvent(userChannelId: UserChannelId): Result { return lightningRepo.nodeEvents.watchUntil { event -> when (event) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index d97cc12d32..dde63848cb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -167,6 +167,7 @@ import to.bitkit.ui.navToActivityDetail import to.bitkit.ui.navigateTo import to.bitkit.ui.navigateToAllActivity import to.bitkit.ui.navigateToProfile +import to.bitkit.ui.navigateToTransferToSpending import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.screens.wallets.activity.components.ActivityListSimple import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems @@ -313,7 +314,7 @@ fun HomeScreen( appViewModel.showSheet(Sheet.Hardware()) } - Suggestion.LIGHTNING -> appViewModel.showSheet(Sheet.TransferUnavailable) + Suggestion.LIGHTNING -> rootNavController.navigateToTransferToSpending(appViewModel) Suggestion.BACK_UP -> { appViewModel.showSheet(Sheet.Backup(BackupRoute.Intro)) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt index 273465d5a0..ad20f92536 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.env.Env import to.bitkit.models.ReceiveAdditionalLiquidityAction import to.bitkit.models.ReceiveAdditionalLiquidityParams import to.bitkit.models.ReceiveLiquidityDecision @@ -45,6 +46,7 @@ class EditInvoiceVM @Inject constructor( source = source, invoiceAmountSats = amountSats, inboundCapacitySats = inboundCapacitySats, + hasPrimaryNode = Env.primaryNode != null, ) ) editInvoiceEffect(EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(action)) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 2b96dce0ba..d90f7de892 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -64,6 +64,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R +import to.bitkit.env.Env import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.setClipboardText import to.bitkit.models.NodeLifecycleState @@ -139,6 +140,7 @@ fun ReceiveQrScreen( hasReadyChannels = hasReadyChannels, inboundCapacitySats = readyInboundLiquiditySats, invoiceAmountSats = walletState.bip21AmountSats, + hasPrimaryNode = Env.primaryNode != null, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index f0de3aaead..358026f2d1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -27,6 +27,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import kotlinx.serialization.Serializable import to.bitkit.R +import to.bitkit.env.Env import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.models.PubkyPublicKeyFormat @@ -123,6 +124,13 @@ fun ReceiveSheet( } } + fun editSoftwareInvoice(tab: ReceiveTab) { + editInvoiceSourceTab = tab + invoiceEditState.beginSoftwareEdit(tab) + resetEditInvoiceAmount() + navController.navigateTo(ReceiveRoute.EditInvoice) + } + LaunchedEffect(Unit) { wallet.resetPreActivityMetadataTagsForCurrentInvoice() wallet.refreshReceiveState() @@ -148,13 +156,14 @@ fun ReceiveSheet( onUsdtSelected = onUsdtSelected, walletState = walletState, lightningState = lightningState, - onClickReceiveCjit = { navController.navigateTo(ReceiveRoute.TransferUnavailable) }, - onClickEditInvoice = { - editInvoiceSourceTab = it - invoiceEditState.beginSoftwareEdit(it) - resetEditInvoiceAmount() - navController.navigateTo(ReceiveRoute.EditInvoice) + onClickReceiveCjit = { + if (Env.primaryNode != null) { + editSoftwareInvoice(ReceiveTab.SPENDING) + } else { + navController.navigateTo(ReceiveRoute.TransferUnavailable) + } }, + onClickEditInvoice = ::editSoftwareInvoice, onClickHardwareEditInvoice = { editInvoiceSourceTab = ReceiveTab.HARDWARE invoiceEditState.beginHardwareEdit() diff --git a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt index 0961142888..85495fa842 100644 --- a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt +++ b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt @@ -13,6 +13,37 @@ class ReceiveLiquidityDecisionTest { inboundCapacitySats = 1_000u, ) + @Test + fun `primary node covers fixed amounts without channels`() { + assertTrue( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = false, + inboundCapacitySats = null, + invoiceAmountSats = 5_000u, + hasPrimaryNode = true, + ) + ) + + assertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = false, + inboundCapacitySats = null, + invoiceAmountSats = null, + hasPrimaryNode = true, + ) + ) + } + + @Test + fun `primary node needs no additional liquidity action`() { + assertEquals( + ReceiveAdditionalLiquidityAction.None, + ReceiveLiquidityDecision.additionalLiquidityAction( + defaultAdditionalLiquidityParams.copy(hasPrimaryNode = true) + ), + ) + } + @Test fun `lightning invoice requires ready channel`() { assertFalse( diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index ca4faba2ef..a719e272c9 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -966,6 +966,7 @@ class LightningServiceTest : BaseUnitTest() { addressType = AddressType.NATIVE_SEGWIT, addressTypesToMonitor = emptyList(), onchainWalletAccounts = emptyList(), + primaryNodeId = null, ) val config = nodeConfig(base = base, storageDirPath = "ldk", onchainWalletAccounts = emptyList()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9767d1a465..0c9e64a188 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", vers hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltAndroidx" } hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltAndroidx" } hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltAndroidx" } +atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version = "0.27.0" } jna = { module = "net.java.dev.jna:jna", version = "5.18.1" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.3.8" } @@ -66,7 +67,8 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.78" } +# Lightning-first test build of coreyphillips/ldk-node feat/lfbw-wallet-client, hosted as a release asset +ldk-node-android = { module = "lfbw:ldk-node-android", version = "0.7.0-rc.77-lfbw.1" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 00170049a1..70451f9c90 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -63,6 +63,12 @@ dependencyResolutionManagement { } google() mavenCentral() + ivy { + url = uri("https://github.com/ovitrif/ldk-node/releases/download") + patternLayout { artifact("v[revision]/[artifact]-[revision].[ext]") } + metadataSources { artifact() } + content { includeGroup("lfbw") } + } maven { url = uri("https://maven.pkg.github.com/synonymdev/bitkit-core") credentials { From bb59ff1708f8690c321abadbc4dad1a48e2a2d4f Mon Sep 17 00:00:00 2001 From: ovitrif Date: Wed, 28 Oct 2026 16:42:34 +0100 Subject: [PATCH 02/10] chore: add changelog fragment --- changelog.d/next/1728.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1728.added.md diff --git a/changelog.d/next/1728.added.md b/changelog.d/next/1728.added.md new file mode 100644 index 0000000000..f6b7bd3de9 --- /dev/null +++ b/changelog.d/next/1728.added.md @@ -0,0 +1 @@ +Lightning can receive and transfer to spending through a primary node that opens or grows a channel just in time. From 74bcfd5f94fafb8cb7da2bb801c969de39c795af Mon Sep 17 00:00:00 2001 From: ovitrif Date: Wed, 28 Oct 2026 18:39:51 +0100 Subject: [PATCH 03/10] feat: primary node override for regtest builds --- app/build.gradle.kts | 1 + app/src/main/java/to/bitkit/env/Env.kt | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 63a1ad1940..341be2e57c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -356,6 +356,7 @@ androidComponents { "USDT_RPC_URL", localProp("USDT_RPC_URL").orElse("https://bitkit.to/v1/usdt/chain-rpc").stringField(), ) + buildConfigFields.put("PRIMARY_NODE_URI", localProp("PRIMARY_NODE_URI").orElse("").stringField()) buildConfigFields.put("E2E", e2eEnv.booleanField()) buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index becba6e25b..d6cf14b536 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -42,9 +42,13 @@ internal object Env { val e2eHomeserverPubky = BuildConfig.E2E_HOMESERVER_PUBKY.takeIf { isLocalE2eBackend && it.isNotBlank() } val network = Network.valueOf(BuildConfig.NETWORK) - /** The Lightning-first primary node: it funds channels just in time and grows them by splicing. */ - val primaryNode: PeerDetails? get() = when (network) { - Network.BITCOIN -> PeerDetails.of(PRIMARY_NODE_URI_MAINNET) + /** + * The Lightning-first primary node: it funds channels just in time and grows them by splicing. + * A `PRIMARY_NODE_URI` build property overrides it, for regtest runs against a local primary. + */ + val primaryNode: PeerDetails? get() = when { + BuildConfig.PRIMARY_NODE_URI.isNotBlank() -> PeerDetails.of(BuildConfig.PRIMARY_NODE_URI) + network == Network.BITCOIN -> PeerDetails.of(PRIMARY_NODE_URI_MAINNET) else -> null } val locales = BuildConfig.LOCALES.split(",") From f3e5914c906a66a91c28fdfd01781ca3ae64efbe Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 04:14:11 +0100 Subject: [PATCH 04/10] feat: primary node pairing and receive fee review --- .../main/java/to/bitkit/data/SettingsStore.kt | 4 + app/src/main/java/to/bitkit/env/Env.kt | 12 +- .../main/java/to/bitkit/models/PrimaryNode.kt | 73 +++++ .../bitkit/models/ReceiveLiquidityDecision.kt | 10 +- .../to/bitkit/repositories/LightningRepo.kt | 38 ++- .../to/bitkit/repositories/PrimaryNodeRepo.kt | 276 ++++++++++++++++++ .../java/to/bitkit/repositories/WalletRepo.kt | 39 ++- .../to/bitkit/services/LightningService.kt | 111 +++++-- app/src/main/java/to/bitkit/ui/ContentView.kt | 18 +- .../external/ExternalNodeViewModel.kt | 54 +--- .../bitkit/ui/screens/wallets/HomeScreen.kt | 3 +- .../wallets/receive/EditInvoiceScreen.kt | 2 + .../screens/wallets/receive/EditInvoiceVM.kt | 5 +- .../receive/PrimaryReceiveConfirmScreen.kt | 159 ++++++++++ .../receive/PrimaryReceiveViewModel.kt | 97 ++++++ .../wallets/receive/ReceiveQrScreen.kt | 2 - .../screens/wallets/receive/ReceiveSheet.kt | 39 ++- .../to/bitkit/ui/settings/SettingsScreen.kt | 8 + .../settings/primaryNode/PrimaryNodeScreen.kt | 195 +++++++++++++ .../primaryNode/PrimaryNodeViewModel.kt | 107 +++++++ .../java/to/bitkit/ui/utils/PrimaryErrors.kt | 24 ++ app/src/main/java/to/bitkit/utils/Errors.kt | 1 + app/src/main/res/values/strings.xml | 28 ++ .../models/ReceiveLiquidityDecisionTest.kt | 31 -- gradle/libs.versions.toml | 4 +- 25 files changed, 1188 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/PrimaryNode.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveConfirmScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveViewModel.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeViewModel.kt create mode 100644 app/src/main/java/to/bitkit/ui/utils/PrimaryErrors.kt diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 10430365ad..27f9bf586a 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -239,6 +239,10 @@ data class SettingsData( val coinSelectPreference: CoinSelectionPreference = CoinSelectionPreference.BranchAndBound, val electrumServer: String = Env.electrumServerUrl, val rgsServerUrl: String? = Env.ldkRgsServerUrl, + /** The paired primary node's URI; blank means the network's default primary, see [Env.defaultPrimaryNodeUri]. */ + val primaryNodeUri: String = "", + /** Set by a transfer to savings, so confirmed savings are not moved back into the primary channel. */ + val primaryFundingPaused: Boolean = false, val selectedAddressType: String = DEFAULT_ADDRESS_TYPE_STRING, val addressTypesToMonitor: List = listOf(DEFAULT_ADDRESS_TYPE_STRING), val pendingRestoreAddressTypePrune: Boolean = false, diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index d6cf14b536..f59c21cbd1 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -4,11 +4,9 @@ import android.os.Build import com.synonym.bitkitcore.UsdtDestination import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Network -import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.BuildConfig import to.bitkit.BuildConfig.VERSION_NAME import to.bitkit.ext.ensureDir -import to.bitkit.ext.of import to.bitkit.utils.Logger import java.io.File import kotlin.io.path.Path @@ -43,13 +41,13 @@ internal object Env { val network = Network.valueOf(BuildConfig.NETWORK) /** - * The Lightning-first primary node: it funds channels just in time and grows them by splicing. + * The primary node a wallet uses until the user pairs another one in Settings. * A `PRIMARY_NODE_URI` build property overrides it, for regtest runs against a local primary. */ - val primaryNode: PeerDetails? get() = when { - BuildConfig.PRIMARY_NODE_URI.isNotBlank() -> PeerDetails.of(BuildConfig.PRIMARY_NODE_URI) - network == Network.BITCOIN -> PeerDetails.of(PRIMARY_NODE_URI_MAINNET) - else -> null + val defaultPrimaryNodeUri: String get() = when { + BuildConfig.PRIMARY_NODE_URI.isNotBlank() -> BuildConfig.PRIMARY_NODE_URI + network == Network.BITCOIN -> PRIMARY_NODE_URI_MAINNET + else -> "" } val locales = BuildConfig.LOCALES.split(",") const val walletSyncIntervalSecs = 10_uL diff --git a/app/src/main/java/to/bitkit/models/PrimaryNode.kt b/app/src/main/java/to/bitkit/models/PrimaryNode.kt new file mode 100644 index 0000000000..8755cdf0f6 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PrimaryNode.kt @@ -0,0 +1,73 @@ +package to.bitkit.models + +import androidx.compose.runtime.Immutable +import org.bouncycastle.asn1.sec.SECNamedCurves +import org.lightningdevkit.ldknode.PeerDetails +import to.bitkit.env.Env +import to.bitkit.ext.fromHex +import to.bitkit.utils.AppError +import java.net.URI +import java.util.Locale + +/** + * The Lightning node that serves this wallet: it opens a channel just in time for the first receive, + * grows it by splicing, and is the wallet's only Lightning peer. + */ +@Immutable +data class PrimaryNode( + val nodeId: String, + val address: String? = null, +) { + val uri: String get() = address?.let { "$nodeId@$it" } ?: nodeId + + companion object { + private val publicKeyPattern = Regex("^(02|03)[0-9a-fA-F]{64}$") + private val hostnamePattern = Regex("^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$") + private val uriPrefix = Regex("^(?:lightning:/{0,2}|node://)", RegexOption.IGNORE_CASE) + private val curve = SECNamedCurves.getByName("secp256k1").curve + + /** Parses a `pubkey` or `pubkey@host:port` node URI, as pasted or scanned. */ + fun parse(input: String): PrimaryNode { + val value = input.trim().replaceFirst(uriPrefix, "") + val parts = value.split('@') + if (parts.size !in 1..2 || value.any(Char::isWhitespace)) throw PrimaryNodeError.InvalidInput() + val nodeId = parts.first().lowercase(Locale.ROOT) + if (!publicKeyPattern.matches(nodeId) || !isValidPublicKey(nodeId)) throw PrimaryNodeError.InvalidInput() + return PrimaryNode(nodeId, parts.getOrNull(1)?.let(::parseAddress)) + } + + fun parseOrNull(input: String): PrimaryNode? = runCatching { parse(input) }.getOrNull() + + /** The primary the wallet uses: the paired one, else the network's default. */ + fun effective(savedUri: String): PrimaryNode? = parseOrNull(savedUri.ifBlank { Env.defaultPrimaryNodeUri }) + + private fun isValidPublicKey(value: String): Boolean = runCatching { + val point = curve.decodePoint(value.fromHex()) + point.isValid && !point.isInfinity + }.getOrDefault(false) + + private fun parseAddress(value: String): String { + val endpoint = runCatching { URI("tcp://$value") }.getOrNull() ?: throw PrimaryNodeError.InvalidInput() + val host = endpoint.host ?: throw PrimaryNodeError.InvalidInput() + val isClean = endpoint.userInfo == null && endpoint.path.isNullOrEmpty() && + endpoint.query == null && endpoint.fragment == null + if (endpoint.port !in 1..MAX_VALID_PORT || !isClean || + (!host.startsWith('[') && !hostnamePattern.matches(host)) + ) { + throw PrimaryNodeError.InvalidInput() + } + return "${host.lowercase(Locale.ROOT)}:${endpoint.port}" + } + } +} + +fun PrimaryNode.toPeerDetails(): PeerDetails? = address?.let { + PeerDetails(nodeId = nodeId, address = it, isConnected = false, isPersisted = true) +} + +sealed class PrimaryNodeError(message: String) : AppError(message) { + class InvalidInput : PrimaryNodeError("Enter a valid Lightning node public key or publickey@host:port URI.") + class AddressUnknown : PrimaryNodeError("No address is known for this node. A full node URI is required.") + class ConnectionInUse : PrimaryNodeError("Close the channel with the current primary node before changing it.") + class NotConfigured : PrimaryNodeError("No primary node is set.") +} diff --git a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt index c1e08fbec9..9b525c9731 100644 --- a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt +++ b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt @@ -11,6 +11,9 @@ sealed interface ReceiveAdditionalLiquidityAction { /** More inbound capacity needs a new channel, and Blocktank no longer sells them. */ data object TransferUnavailable : ReceiveAdditionalLiquidityAction + + /** The primary node opens or grows the channel for this receive, once the user approves its fee. */ + data object PrimaryReceive : ReceiveAdditionalLiquidityAction } data class ReceiveAdditionalLiquidityParams( @@ -25,9 +28,7 @@ object ReceiveLiquidityDecision { hasReadyChannels: Boolean, inboundCapacitySats: ULong?, invoiceAmountSats: ULong?, - hasPrimaryNode: Boolean = false, ): Boolean { - if (hasPrimaryNode && (invoiceAmountSats ?: 0uL) > 0uL) return true if (!hasReadyChannels || inboundCapacitySats == null) return false if (invoiceAmountSats == null || invoiceAmountSats == 0uL) { @@ -39,10 +40,11 @@ object ReceiveLiquidityDecision { fun additionalLiquidityAction(params: ReceiveAdditionalLiquidityParams): ReceiveAdditionalLiquidityAction { return when { - params.source != ReceiveLiquiditySource.SPENDING -> ReceiveAdditionalLiquidityAction.None - params.hasPrimaryNode -> ReceiveAdditionalLiquidityAction.None + params.source == ReceiveLiquiditySource.SAVINGS -> ReceiveAdditionalLiquidityAction.None !needsInboundLiquidity(params.invoiceAmountSats, params.inboundCapacitySats) -> ReceiveAdditionalLiquidityAction.None + params.hasPrimaryNode && params.invoiceAmountSats > 0uL -> ReceiveAdditionalLiquidityAction.PrimaryReceive + params.source != ReceiveLiquiditySource.SPENDING -> ReceiveAdditionalLiquidityAction.None (params.inboundCapacitySats ?: 0uL) == 0uL -> ReceiveAdditionalLiquidityAction.None else -> ReceiveAdditionalLiquidityAction.TransferUnavailable } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 76757d1444..c009ab9adf 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -56,6 +56,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentHash import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails +import org.lightningdevkit.ldknode.PrimaryReceiveQuote import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid import to.bitkit.async.appScope @@ -79,6 +80,7 @@ import to.bitkit.models.ElectrumServer import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult +import to.bitkit.models.PrimaryNode import to.bitkit.models.TransactionSpeed import to.bitkit.models.WalletScope import to.bitkit.models.safe @@ -1300,11 +1302,33 @@ class LightningRepo @Inject constructor( runCatching { lightningService.receive(amountSats, description, expirySeconds) } } + /** The primary node the running node was built with. */ + val primaryNode: PrimaryNode? get() = lightningService.primaryNode + + suspend fun quotePrimaryReceive(amountSats: ULong): Result = + executeWhenNodeRunning("quotePrimaryReceive") { + runSuspendCatching { lightningService.quotePrimaryReceive(amountSats) }.also { refreshChannelsAndPeers() } + } + suspend fun createPrimaryInvoice( amountSats: ULong, description: String, + maxFeeMsat: ULong, ): Result = executeWhenNodeRunning("createPrimaryInvoice") { - runSuspendCatching { lightningService.receiveViaPrimary(amountSats, description) } + requireWalletRestoreComplete() + runSuspendCatching { lightningService.receiveViaPrimary(amountSats, description, maxFeeMsat) } + } + + suspend fun checkPrimaryConnection(nodeId: String): Result = executeWhenNodeRunning("checkPrimaryConnection") { + runSuspendCatching { lightningService.checkPrimaryConnection(nodeId) }.also { refreshChannelsAndPeers() } + } + + suspend fun hasPendingFundingOperation(): Result = executeWhenNodeRunning("hasPendingFundingOperation") { + runSuspendCatching { lightningService.hasPendingFundingOperation() } + } + + suspend fun channelFundingFeeRate(): Result = executeWhenNodeRunning("channelFundingFeeRate") { + runSuspendCatching { lightningService.channelFundingFeeRate() } } suspend fun createInvoiceMsats( @@ -2106,6 +2130,18 @@ class LightningRepo @Inject constructor( } } + suspend fun spliceOut( + channel: ChannelDetails, + address: String, + amountSats: ULong, + feeRateSatsPerVByte: ULong, + ): Result = executeWhenNodeRunning("spliceOut") { + requireWalletRestoreComplete() + lightningService.spliceOut(channel, address, amountSats, feeRateSatsPerVByte).also { + syncState() + } + } + suspend fun closeChannel( channel: ChannelDetails, force: Boolean = false, diff --git a/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt new file mode 100644 index 0000000000..6de0b5beed --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt @@ -0,0 +1,276 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.NodeException +import org.lightningdevkit.ldknode.PrimaryReceiveQuote +import to.bitkit.data.SettingsStore +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PrimaryNode +import to.bitkit.models.PrimaryNodeError +import to.bitkit.models.USat +import to.bitkit.models.toPeerDetails +import to.bitkit.utils.AppError +import to.bitkit.utils.LdkError +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Lightning-first wallet operations through the primary node: pairing, just-in-time receives, + * moving confirmed savings into the primary channel, and paying on-chain from that channel. + */ +@Singleton +class PrimaryNodeRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val settingsStore: SettingsStore, + private val lightningRepo: LightningRepo, +) { + companion object { + private const val TAG = "PrimaryNodeRepo" + + /** Confirmed savings below this stay on-chain instead of moving into the primary channel. */ + const val MIN_FUNDING_SATS = 25_000uL + + /** Automatic funding waits while its mining fee is above this share of the amount moved. */ + const val MAX_FUNDING_FEE_PERCENT = 5uL + + /** Virtual size of a splice or channel funding before its inputs, for fee estimates. */ + const val FUNDING_BASE_VBYTES = 300uL + + /** Virtual size each wallet input adds to a funding transaction. */ + const val FUNDING_INPUT_VBYTES = 300uL + } + + private val operationMutex = Mutex() + + /** The primary the wallet uses: the paired one, else the network's default. */ + val primaryNode: Flow = settingsStore.data + .map { PrimaryNode.effective(it.primaryNodeUri) } + .distinctUntilChanged() + + val isFundingPaused: Flow = settingsStore.data.map { it.primaryFundingPaused }.distinctUntilChanged() + + val hasPrimaryNode: Boolean get() = lightningRepo.primaryNode != null + + /** The ready channel with the primary, if the wallet has one. */ + fun primaryChannel(): ChannelDetails? { + val primary = lightningRepo.primaryNode ?: return null + return lightningRepo.lightningState.value.channels.firstOrNull { + it.counterpartyNodeId == primary.nodeId && it.isChannelReady + } + } + + fun isPrimaryConnected(): Boolean { + val primary = lightningRepo.primaryNode ?: return false + return lightningRepo.lightningState.value.peers.any { it.nodeId == primary.nodeId && it.isConnected } + } + + /** + * Pairs [input] as the primary: saves it, restarts the node so it trusts the new primary, connects, + * and checks the node speaks the primary-wallet protocol. A failure restores the previous primary. + */ + suspend fun pair(input: String): Result = withContext(bgDispatcher) { + operationMutex.withLock { + runSuspendCatching { + val parsed = PrimaryNode.parse(input) + val primary = if (parsed.address != null) parsed else parsed.copy(address = knownAddress(parsed.nodeId)) + val current = lightningRepo.primaryNode + val hasChannelWithCurrent = current != null && current.nodeId != primary.nodeId && + lightningRepo.lightningState.value.channels.any { it.counterpartyNodeId == current.nodeId } + if (hasChannelWithCurrent) throw PrimaryNodeError.ConnectionInUse() + + val previous = settingsStore.data.first() + val savedUri = primary.uri.takeIf { it != Env.defaultPrimaryNodeUri }.orEmpty() + settingsStore.update { it.copy(primaryNodeUri = savedUri, primaryFundingPaused = false) } + verifyPrimary(primary).onFailure { error -> + settingsStore.update { + it.copy( + primaryNodeUri = previous.primaryNodeUri, + primaryFundingPaused = previous.primaryFundingPaused, + ) + } + lightningRepo.restartNode() + throw error + } + primary + } + } + } + + suspend fun quoteReceive(amountSats: ULong): Result = withContext(bgDispatcher) { + lightningRepo.quotePrimaryReceive(amountSats).mapPrimaryReceiveError() + .mapCatching { quote -> + if (quote.amountMsat != amountSats * 1000uL || quote.feeMsat >= quote.amountMsat) { + throw PrimaryReceiveError.AmountTooSmall() + } + quote + } + } + + suspend fun createInvoice( + amountSats: ULong, + description: String, + maxFeeMsat: ULong, + ): Result = withContext(bgDispatcher) { + lightningRepo.createPrimaryInvoice(amountSats, description, maxFeeMsat).mapPrimaryReceiveError() + } + + /** How much of the confirmed savings can move into the primary channel now, and at what fee. */ + suspend fun fundingPlan(): Result = withContext(bgDispatcher) { + runSuspendCatching { + if (lightningRepo.primaryNode == null || !isPrimaryConnected()) return@runSuspendCatching null + if (lightningRepo.hasPendingFundingOperation().getOrThrow()) return@runSuspendCatching null + val spendable = lightningRepo.getBalancesAsync().getOrThrow().spendableOnchainBalanceSats + val inputs = lightningRepo.listSpendableOutputs().getOrThrow().size.toULong() + val feeRate = lightningRepo.channelFundingFeeRate().getOrThrow() + primaryFundingPlan(spendable, inputs, feeRate, hasChannel = primaryChannel() != null) + } + } + + /** Moves confirmed savings into the primary channel when the plan allows it and funding isn't paused. */ + suspend fun fundIfNeeded(): Result = withContext(bgDispatcher) { + if (settingsStore.data.first().primaryFundingPaused) return@withContext Result.success(Unit) + val plan = fundingPlan().getOrElse { return@withContext Result.failure(it) } + ?: return@withContext Result.success(Unit) + if (!plan.isAutomatic) return@withContext Result.success(Unit) + Logger.info("Funding primary channel with '${plan.amountSats}' sats, fee '${plan.feeSats}'", context = TAG) + executeFunding(plan) + } + + /** Transfer to Spending: moves the planned savings now, below the automatic fee limit too, and resumes funding. */ + suspend fun transferToSpending(plan: PrimaryFundingPlan): Result = withContext(bgDispatcher) { + settingsStore.update { it.copy(primaryFundingPaused = false) } + executeFunding(plan) + } + + /** Fee for paying [amountSats] on-chain out of the primary channel, or null when the channel can't cover it. */ + fun spliceOutFee(amountSats: ULong, feeRate: ULong): ULong? { + val channel = primaryChannel()?.takeIf { it.isUsable } ?: return null + val fee = feeRate * FUNDING_BASE_VBYTES + return fee.takeIf { USat(amountSats) + USat(it) <= channel.outboundCapacityMsat / 1000uL } + } + + /** The most a single on-chain payment can take out of the primary channel at [feeRate]. */ + fun maxSpliceOutSats(feeRate: ULong): ULong { + val channel = primaryChannel()?.takeIf { it.isUsable } ?: return 0uL + return USat(channel.outboundCapacityMsat / 1000uL) - USat(feeRate * FUNDING_BASE_VBYTES) + } + + /** Pays [amountSats] to [address] from the primary channel by splicing it out. */ + suspend fun sendOnchain(address: String, amountSats: ULong, feeRate: ULong): Result = + withContext(bgDispatcher) { + operationMutex.withLock { + runSuspendCatching { + val channel = primaryChannel()?.takeIf { it.isUsable } ?: throw PrimaryNodeError.NotConfigured() + if (lightningRepo.hasPendingFundingOperation().getOrThrow()) throw PrimaryWalletBusyError() + lightningRepo.spliceOut(channel, address, amountSats, feeRate).getOrThrow() + } + } + } + + /** Transfer to Savings: splices the channel's spendable balance out to the wallet and pauses funding. */ + suspend fun transferToSavings(feeRate: ULong): Result = withContext(bgDispatcher) { + runSuspendCatching { + val amount = maxSpliceOutSats(feeRate) + if (amount == 0uL) throw PrimaryNodeError.NotConfigured() + val address = lightningRepo.newAddress().getOrThrow() + settingsStore.update { it.copy(primaryFundingPaused = true) } + sendOnchain(address, amount, feeRate).onFailure { + settingsStore.update { settings -> settings.copy(primaryFundingPaused = false) } + }.getOrThrow() + amount + } + } + + private suspend fun executeFunding(plan: PrimaryFundingPlan): Result = operationMutex.withLock { + runSuspendCatching { + val primary = lightningRepo.primaryNode ?: throw PrimaryNodeError.NotConfigured() + val channel = primaryChannel() + if (channel != null) { + lightningRepo.spliceIn(channel, plan.amountSats).getOrThrow() + } else { + val peer = primary.toPeerDetails() ?: throw PrimaryNodeError.AddressUnknown() + lightningRepo.openChannel(peer, plan.amountSats).getOrThrow() + } + Unit + } + } + + private suspend fun verifyPrimary(primary: PrimaryNode): Result = runSuspendCatching { + lightningRepo.restartNode().getOrThrow() + val peer = primary.toPeerDetails() ?: throw PrimaryNodeError.AddressUnknown() + lightningRepo.connectPeer(peer).mapPrimaryReceiveError().getOrThrow() + lightningRepo.checkPrimaryConnection(primary.nodeId).mapPrimaryReceiveError().getOrThrow() + } + + private fun knownAddress(nodeId: String): String = lightningRepo.lightningState.value.peers + .firstOrNull { it.nodeId == nodeId }?.address + ?: throw PrimaryNodeError.AddressUnknown() +} + +/** A move of confirmed savings into the primary channel: [amountSats] arrives, [feeSats] pays the miners. */ +data class PrimaryFundingPlan( + val amountSats: ULong, + val feeSats: ULong, + val opensChannel: Boolean, + val isAutomatic: Boolean, +) + +/** + * Plans moving [spendableSats] of savings, held in [inputs] outputs, into the primary channel at [feeRate]. + * Returns null when there is nothing worth moving; [PrimaryFundingPlan.isAutomatic] is false when the + * fee is above [PrimaryNodeRepo.MAX_FUNDING_FEE_PERCENT], so only a manual transfer moves it. + */ +internal fun primaryFundingPlan( + spendableSats: ULong, + inputs: ULong, + feeRate: ULong, + hasChannel: Boolean, +): PrimaryFundingPlan? { + if (inputs == 0uL || feeRate == 0uL) return null + val fee = feeRate * (PrimaryNodeRepo.FUNDING_BASE_VBYTES + PrimaryNodeRepo.FUNDING_INPUT_VBYTES * inputs) + if (USat(spendableSats) <= USat(fee)) return null + val amount = USat(spendableSats) - USat(fee) + return PrimaryFundingPlan( + amountSats = amount, + feeSats = fee, + opensChannel = !hasChannel, + isAutomatic = spendableSats >= PrimaryNodeRepo.MIN_FUNDING_SATS && + fee <= spendableSats / 100uL * PrimaryNodeRepo.MAX_FUNDING_FEE_PERCENT, + ) +} + +class PrimaryWalletBusyError : AppError("A primary channel operation is still pending. Try again once it confirms.") + +/** Why the primary node could not provide a receive, shown to the user. */ +sealed class PrimaryReceiveError(message: String, cause: Throwable? = null) : AppError(message, cause) { + class InsufficientFunds : PrimaryReceiveError("The primary node does not have enough funds for this amount.") + class Rejected : PrimaryReceiveError("The primary node declined this amount.") + class TimedOut : PrimaryReceiveError("The primary node did not respond.") + class AmountTooSmall : PrimaryReceiveError("This amount is too small to cover the receive fee.") + class Offline(cause: Throwable?) : PrimaryReceiveError("The primary node is offline or unreachable.", cause) +} + +internal fun Result.mapPrimaryReceiveError(): Result = recoverCatching { throw it.toPrimaryReceiveError() } + +internal fun Throwable.toPrimaryReceiveError(): Throwable = when ((this as? LdkError)?.nodeException) { + is NodeException.LiquiditySourceInsufficientFunds -> PrimaryReceiveError.InsufficientFunds() + is NodeException.LiquidityRequestRejected -> PrimaryReceiveError.Rejected() + is NodeException.LiquidityRequestTimedOut -> PrimaryReceiveError.TimedOut() + is NodeException.LiquidityReceiveAmountTooSmall -> PrimaryReceiveError.AmountTooSmall() + is NodeException.ConnectionFailed, + is NodeException.LiquiditySourceUnavailable, + is NodeException.NotRunning, + -> PrimaryReceiveError.Offline(this) + else -> this +} diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 8fa7307427..ca7a26bc32 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -76,6 +76,7 @@ class WalletRepo @Inject constructor( private val activityRepo: ActivityRepo, private val hwWalletRepo: HwWalletRepo, private val connectionRecovery: WalletConnectionRecovery, + private val primaryNodeRepo: PrimaryNodeRepo, ) { private val repoScope = appScope(bgDispatcher, TAG) @@ -86,6 +87,7 @@ class WalletRepo @Inject constructor( val balanceState = _balanceState.asStateFlow() private var eventSyncJob: Job? = null + private var primaryFundingJob: Job? = null private val receiveStateMutex = Mutex() private val receiveUpdateStatusLock = Any() private var pendingReceiveUpdates = 0 @@ -386,6 +388,7 @@ class WalletRepo @Inject constructor( syncBalances() lightningRepo.sync().onSuccess { syncBalances() + schedulePrimaryFunding() }.onFailure { if (it is TimeoutCancellationException) { syncBalances() @@ -425,6 +428,16 @@ class WalletRepo @Inject constructor( syncBalances() transferRepo.syncTransferStates() activityRepo.syncActivities() + schedulePrimaryFunding() + } + } + + private fun schedulePrimaryFunding() { + if (primaryFundingJob?.isActive == true) return + primaryFundingJob = repoScope.launch { + primaryNodeRepo.fundIfNeeded().onFailure { + Logger.warn("Failed to fund primary channel", it, context = TAG) + } } } @@ -929,6 +942,19 @@ class WalletRepo @Inject constructor( updateBip21InvoiceLocked(amountSats, description) } + /** Shows [bolt11], an invoice the primary node delivers, as the current receive request for [amountSats]. */ + suspend fun setPrimaryReceiveInvoice( + bolt11: String, + amountSats: ULong, + description: String, + ): Result = updateReceiveState( + beforeLock = { + _walletState.update { it.copy(bip21AmountSats = amountSats, bip21Description = description) } + }, + ) { + runSuspendCatching { setBolt11Locked(bolt11, amountSats, description) } + } + private suspend fun updateBip21InvoiceLocked( amountSats: ULong? = walletState.value.bip21AmountSats, description: String = walletState.value.bip21Description, @@ -945,15 +971,10 @@ class WalletRepo @Inject constructor( emptyList() } - val bolt11 = when { - canCreateLightningInvoice(amountSats) -> - lightningRepo.createInvoice(amountSats, description).getOrThrow() - amountSats != null && Env.primaryNode != null -> - lightningRepo.createPrimaryInvoice(amountSats, description).getOrElse { - Logger.warn("Failed to create primary invoice for '$amountSats' sats", it, context = TAG) - "" - } - else -> "" + val bolt11 = if (canCreateLightningInvoice(amountSats)) { + lightningRepo.createInvoice(amountSats, description).getOrThrow() + } else { + "" } setBolt11Locked(bolt11, amountSats, description) val newBip21Url = _walletState.value.bip21 diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index dd59f880c1..4c73450d17 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -37,6 +37,7 @@ import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.PrimaryReceiveQuote import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.OnchainSendResult @@ -71,6 +72,9 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.uByteList import to.bitkit.ext.uri import to.bitkit.models.OpenChannelResult +import to.bitkit.models.PrimaryNode +import to.bitkit.models.PrimaryNodeError +import to.bitkit.models.toPeerDetails import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE import to.bitkit.models.WatchOnlyAccountRecord @@ -207,7 +211,10 @@ class LightningService internal constructor( private const val NODE_ID_PREVIEW_LEN = 20 /** Primary nodes authorize a just-in-time receive for ten minutes at most. */ - private const val PRIMARY_INVOICE_EXPIRY_SECS = 600u + const val PRIMARY_INVOICE_EXPIRY_SECS = 600u + + /** Inbound capacity a primary leaves on top of a receive, so the next payments fit without a splice. */ + private const val PRIMARY_INBOUND_HEADROOM_SATS = 10_000uL /** LDK's defaults for route parameters, kept when only the routing fee is capped. */ private const val DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA = 1008u @@ -270,6 +277,11 @@ class LightningService internal constructor( @Volatile var node: Node? = null + /** The primary node the running node was built with, or null when the network has none. */ + @Volatile + var primaryNode: PrimaryNode? = null + private set + @Volatile var currentWalletIndex: Int = 0 private set @@ -345,7 +357,8 @@ class LightningService internal constructor( setScoringFeeParams(scorerFeeParameters()) setAddressType(selectedType) setAddressTypesToMonitor(monitoredTypes) - Env.primaryNode?.let { setPrimaryNode(it.nodeId) } + primaryNode = PrimaryNode.effective(settings.primaryNodeUri) + primaryNode?.let { setPrimaryNode(it.nodeId) } if (channelMigration != null) { setChannelDataMigration(channelMigration) @@ -433,7 +446,7 @@ class LightningService internal constructor( } reconcileWatchOnlyAccountsBestEffort() - Env.primaryNode?.let { connectPeer(it) } + primaryNode?.toPeerDetails()?.let { connectPeer(it) } // start event listener after node started onEvent?.let { eventHandler -> @@ -754,7 +767,7 @@ class LightningService internal constructor( ServiceQueue.LDK.background(ldkQueue) { val balances = node.listBalances() val idlePeerIds = idlePeerIds( - peers = node.listPeers().filter { it.nodeId != Env.primaryNode?.nodeId }, + peers = node.listPeers().filter { it.nodeId != primaryNode?.nodeId }, channels = node.listChannels(), lightningBalances = balances.lightningBalances, pendingSweeps = balances.pendingBalancesFromChannelClosures, @@ -819,6 +832,41 @@ class LightningService internal constructor( } } + suspend fun spliceOut( + channel: ChannelDetails, + address: Address, + amountSats: ULong, + feeRateSatsPerVByte: ULong, + ): Result { + val node = this.node ?: throw ServiceError.NodeNotSetup() + + return ServiceQueue.LDK.background(ldkQueue) { + try { + Logger.debug("Splicing out '$amountSats' sats from channel '${channel.channelId}'", context = TAG) + node.spliceOutWithFeeRate( + channel.userChannelId, + channel.counterpartyNodeId, + address, + amountSats, + feeRateSatsPerVByte, + ) + Result.success(Unit) + } catch (e: NodeException) { + Result.failure(LdkError(e)) + } + } + } + + suspend fun hasPendingFundingOperation(): Boolean { + val node = this.node ?: throw ServiceError.NodeNotSetup() + return ServiceQueue.LDK.background(ldkQueue) { node.hasPendingFundingOperation() } + } + + suspend fun channelFundingFeeRate(): ULong { + val node = this.node ?: throw ServiceError.NodeNotSetup() + return ServiceQueue.LDK.background(ldkQueue) { node.estimatedFeeRates().channelFundingSatPerVbyte } + } + suspend fun openChannel( peer: PeerDetails, channelAmountSats: ULong, @@ -961,29 +1009,36 @@ class LightningService internal constructor( } } - /** - * Creates an invoice the primary node delivers through a channel it opens or grows just in time, - * accepting at most the fee it quotes now. - */ - suspend fun receiveViaPrimary(sat: ULong, description: String): String { + /** Quotes the fee the primary node charges to deliver [sat] through a channel it opens or grows. */ + suspend fun quotePrimaryReceive(sat: ULong): PrimaryReceiveQuote { val node = this.node ?: throw ServiceError.NodeNotSetup() - val primary = requireNotNull(Env.primaryNode) { "No primary node for '${Env.network}'" } - val amountMsat = sat * 1000u + val primary = primaryNode ?: throw PrimaryNodeError.NotConfigured() return ServiceQueue.LDK.background(ldkQueue) { try { - if (node.listPeers().none { it.nodeId == primary.nodeId && it.isConnected }) { - node.connect(primary.nodeId, primary.address, persist = true) - } - val quote = node.quotePrimaryReceive(primary.nodeId, amountMsat, targetInboundSats = 0u) - Logger.info("Received primary quote '$quote'", context = TAG) + node.connectPrimaryIfNeeded(primary) + node.quotePrimaryReceive(primary.nodeId, sat * 1000u, PRIMARY_INBOUND_HEADROOM_SATS) + } catch (e: NodeException) { + throw LdkError(e) + } + } + } + + /** Creates an invoice the primary node delivers, accepting at most the [maxFeeMsat] the user approved. */ + suspend fun receiveViaPrimary(sat: ULong, description: String, maxFeeMsat: ULong): String { + val node = this.node ?: throw ServiceError.NodeNotSetup() + val primary = primaryNode ?: throw PrimaryNodeError.NotConfigured() + + return ServiceQueue.LDK.background(ldkQueue) { + try { + node.connectPrimaryIfNeeded(primary) node.receiveViaPrimary( nodeId = primary.nodeId, - amountMsat = amountMsat, + amountMsat = sat * 1000u, description = Bolt11InvoiceDescription.Direct(description = description), expirySecs = PRIMARY_INVOICE_EXPIRY_SECS, - maxFeeMsat = quote.feeMsat, - targetInboundSats = 0u, + maxFeeMsat = maxFeeMsat, + targetInboundSats = PRIMARY_INBOUND_HEADROOM_SATS, ).toString() } catch (e: NodeException) { throw LdkError(e) @@ -991,6 +1046,24 @@ class LightningService internal constructor( } } + /** Checks that the connected primary speaks the primary-wallet protocol; a funding refusal still passes. */ + suspend fun checkPrimaryConnection(nodeId: String) { + val node = this.node ?: throw ServiceError.NodeNotSetup() + ServiceQueue.LDK.background(ldkQueue) { + try { + node.checkPrimaryConnection(nodeId) + } catch (e: NodeException) { + throw LdkError(e) + } + } + } + + private fun Node.connectPrimaryIfNeeded(primary: PrimaryNode) { + if (listPeers().any { it.nodeId == primary.nodeId && it.isConnected }) return + val address = primary.address ?: throw PrimaryNodeError.AddressUnknown() + connect(primary.nodeId, address, persist = true) + } + /** Prepare without dispatch; the caller must durably retain the receipt before calling broadcast. */ suspend fun prepareOnchainSend( address: Address, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index db89ef0f29..2d9d1a027c 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -63,7 +63,6 @@ import to.bitkit.appwidget.AppWidgetRefreshReason import to.bitkit.appwidget.appWidgetRefreshScheduler import to.bitkit.env.Env import to.bitkit.ext.rawId -import to.bitkit.ext.uri import to.bitkit.ext.walletId import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast @@ -195,6 +194,7 @@ import to.bitkit.ui.settings.lightning.CloseConnectionScreen import to.bitkit.ui.settings.lightning.LightningConnectionsScreen import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel import to.bitkit.ui.settings.pin.PinManagementScreen +import to.bitkit.ui.settings.primaryNode.PrimaryNodeScreen import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen import to.bitkit.ui.settings.quickPay.QuickPaySettingsScreen import to.bitkit.ui.settings.support.ReportIssueResultScreen @@ -1195,7 +1195,7 @@ private fun NavGraphBuilder.home( onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, - onTransferToSpendingClick = { navController.navigateToTransferToSpending(appViewModel) }, + onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, ) @@ -1218,7 +1218,7 @@ private fun NavGraphBuilder.home( navController.navigateToTransferSavingsAvailability() } }, - onTransferFromSavingsClick = { navController.navigateToTransferToSpending(appViewModel) }, + onTransferFromSavingsClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, onBackClick = { navController.popBackStack() }, ) } @@ -1675,6 +1675,9 @@ private fun NavGraphBuilder.advancedSettingsSubScreens(navController: NavHostCon deepLinkableComposable { ElectrumConfigScreen(navController) } + deepLinkableComposable { + PrimaryNodeScreen(navController) + } deepLinkableComposable { RgsServerScreen(navController) } @@ -2077,12 +2080,6 @@ fun NavController.navigateToDevSettings() = navigateTo(Routes.DevSettings) fun NavController.navigateToTransferSavingsIntro() = navigateTo(Routes.SavingsIntro) -/** Opens a channel with the primary node, or explains that transfers need manual setup without one. */ -fun NavController.navigateToTransferToSpending(appViewModel: AppViewModel) { - val primary = Env.primaryNode ?: return appViewModel.showSheet(Sheet.TransferUnavailable) - navigateTo(ExternalConnection(scannedNodeUri = primary.uri)) -} - fun NavController.navigateToTransferSavingsAvailability() = navigateTo(Routes.SavingsAvailability) /** @@ -2196,6 +2193,9 @@ sealed interface Routes { @Serializable data object ElectrumConfig : Routes.DeepLinkable + @Serializable + data object PrimaryNode : Routes.DeepLinkable + @Serializable data object RgsServer : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index e516ef8ecb..9b5ea61897 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.UserChannelId @@ -29,7 +28,6 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.UiState import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -41,10 +39,6 @@ class ExternalNodeViewModel @Inject constructor( private val settingsStore: SettingsStore, private val transferRepo: to.bitkit.repositories.TransferRepo, ) : ViewModel() { - companion object { - private const val TAG = "ExternalNodeViewModel" - } - private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() @@ -141,18 +135,10 @@ class ExternalNodeViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - val peer = requireNotNull(_uiState.value.peer) - val readyChannel = lightningRepo.getChannels() - ?.firstOrNull { it.counterpartyNodeId == peer.nodeId && it.isChannelReady } - if (readyChannel != null) { - spliceIn(readyChannel) - return@launch - } - @Suppress("ForbiddenComment") // TODO: pass customFeeRate to ldk-node when supported lightningRepo.openChannel( - peer = peer, + peer = requireNotNull(_uiState.value.peer), channelAmountSats = _uiState.value.amount.sats.toULong(), ).mapCatching { result -> awaitChannelPendingEvent(result.userChannelId).mapCatching { event -> @@ -182,44 +168,6 @@ class ExternalNodeViewModel @Inject constructor( } } - private suspend fun spliceIn(channel: ChannelDetails) { - val amountSats = _uiState.value.amount.sats.toULong() - lightningRepo.spliceIn(channel, amountSats).mapCatching { - val event = awaitSplicePendingEvent(channel.userChannelId).getOrThrow() - transferRepo.createTransfer( - type = TransferType.MANUAL_SETUP, - amountSats = amountSats.toLong(), - channelId = event.channelId, - fundingTxId = event.newFundingTxo.txid, - ) - }.onSuccess { - setEffect(SideEffect.ConfirmSuccess) - }.onFailure { - Logger.warn("Failed to splice in to channel '${channel.channelId}'", it, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__error_channel_purchase), - description = context.getString(R.string.lightning__error_channel_setup_msg) - .replace("{raw}", it.message.orEmpty()), - ) - } - _uiState.update { it.copy(isLoading = false) } - } - - private suspend fun awaitSplicePendingEvent(userChannelId: UserChannelId): Result { - return lightningRepo.nodeEvents.watchUntil { event -> - when (event) { - is Event.SpliceFailed if event.userChannelId == userChannelId -> - WatchResult.Complete(Result.failure(AppError("Splice failed for '$userChannelId'"))) - is Event.SplicePending if event.userChannelId == userChannelId -> - WatchResult.Complete(Result.success(event)) - else -> WatchResult.Continue() - } - }.let { - checkNotNull(it) { "Timeout in awaitSplicePendingEvent for userChannelId='$userChannelId'" } - } - } - private suspend fun awaitChannelPendingEvent(userChannelId: UserChannelId): Result { return lightningRepo.nodeEvents.watchUntil { event -> when (event) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index dde63848cb..d97cc12d32 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -167,7 +167,6 @@ import to.bitkit.ui.navToActivityDetail import to.bitkit.ui.navigateTo import to.bitkit.ui.navigateToAllActivity import to.bitkit.ui.navigateToProfile -import to.bitkit.ui.navigateToTransferToSpending import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.screens.wallets.activity.components.ActivityListSimple import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems @@ -314,7 +313,7 @@ fun HomeScreen( appViewModel.showSheet(Sheet.Hardware()) } - Suggestion.LIGHTNING -> rootNavController.navigateToTransferToSpending(appViewModel) + Suggestion.LIGHTNING -> appViewModel.showSheet(Sheet.TransferUnavailable) Suggestion.BACK_UP -> { appViewModel.showSheet(Sheet.Backup(BackupRoute.Intro)) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 2ba848186d..37e5f6f086 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -86,6 +86,7 @@ fun EditInvoiceScreen( onchainOnly: Boolean = false, updateOnchainInvoice: (ULong?) -> Unit = {}, navigateTransferUnavailable: () -> Unit, + navigatePrimaryReceive: (ULong) -> Unit, currencies: CurrencyState = LocalCurrencies.current, editInvoiceVM: EditInvoiceVM = hiltViewModel(), ) { @@ -106,6 +107,7 @@ fun EditInvoiceScreen( onBack() } ReceiveAdditionalLiquidityAction.TransferUnavailable -> navigateTransferUnavailable() + ReceiveAdditionalLiquidityAction.PrimaryReceive -> navigatePrimaryReceive(receiveSats) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt index ad20f92536..837d6e5e3e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt @@ -9,17 +9,18 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import to.bitkit.env.Env import to.bitkit.models.ReceiveAdditionalLiquidityAction import to.bitkit.models.ReceiveAdditionalLiquidityParams import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.models.ReceiveLiquiditySource +import to.bitkit.repositories.PrimaryNodeRepo import to.bitkit.repositories.WalletRepo import javax.inject.Inject @HiltViewModel class EditInvoiceVM @Inject constructor( private val walletRepo: WalletRepo, + private val primaryNodeRepo: PrimaryNodeRepo, ) : ViewModel() { private val _editInvoiceEffect = MutableSharedFlow(extraBufferCapacity = 1) @@ -46,7 +47,7 @@ class EditInvoiceVM @Inject constructor( source = source, invoiceAmountSats = amountSats, inboundCapacitySats = inboundCapacitySats, - hasPrimaryNode = Env.primaryNode != null, + hasPrimaryNode = primaryNodeRepo.hasPrimaryNode, ) ) editInvoiceEffect(EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(action)) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveConfirmScreen.kt new file mode 100644 index 0000000000..b88f8bb21e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveConfirmScreen.kt @@ -0,0 +1,159 @@ +package to.bitkit.ui.screens.wallets.receive + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun PrimaryReceiveConfirmScreen( + amountSats: ULong, + viewModel: PrimaryReceiveViewModel, + onBack: () -> Unit, + onCreated: () -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(amountSats) { viewModel.requestQuote(amountSats) } + LaunchedEffect(viewModel) { viewModel.created.collect { onCreated() } } + + Content( + uiState = uiState, + onBack = onBack, + onRetry = viewModel::retry, + onApprove = viewModel::approve, + modifier = modifier, + ) +} + +@Composable +private fun Content( + uiState: PrimaryReceiveUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + onRetry: () -> Unit = {}, + onApprove: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding() + .testTag("PrimaryReceiveConfirm") + ) { + SheetTopBar(titleText = stringResource(R.string.primary_receive__title), onBack = onBack) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + ) { + VerticalSpacer(16.dp) + BalanceHeaderView(sats = uiState.amountSats.toLong(), modifier = Modifier.fillMaxWidth()) + VerticalSpacer(32.dp) + + if (uiState.maxFeeMsat != null) { + Caption13Up(stringResource(R.string.primary_receive__fee), color = Colors.White64) + VerticalSpacer(8.dp) + MoneySSB(sats = uiState.maxFeeSats.toLong(), modifier = Modifier.testTag("PrimaryReceiveFee")) + VerticalSpacer(24.dp) + Caption13Up(stringResource(R.string.primary_receive__minimum), color = Colors.White64) + VerticalSpacer(8.dp) + MoneySSB(sats = uiState.minimumReceivedSats.toLong(), modifier = Modifier.testTag("PrimaryReceiveMin")) + if (uiState.channelFundingSats > 0uL) { + VerticalSpacer(24.dp) + Caption13Up(stringResource(R.string.primary_receive__capacity), color = Colors.White64) + VerticalSpacer(8.dp) + MoneySSB(sats = uiState.channelFundingSats.toLong(), modifier = Modifier.testTag("PrimaryReceiveCapacity")) + } + VerticalSpacer(24.dp) + BodyM(stringResource(R.string.primary_receive__note), color = Colors.White64) + } + + uiState.error?.let { + BodyM(it, color = Colors.Red, modifier = Modifier.testTag("PrimaryReceiveError")) + } + + FillHeight() + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (uiState.error != null) { + SecondaryButton( + text = stringResource(R.string.common__retry), + onClick = onRetry, + enabled = !uiState.isLoading, + modifier = Modifier + .weight(1f) + .testTag("PrimaryReceiveRetry") + ) + } + PrimaryButton( + text = stringResource(R.string.primary_receive__create), + onClick = onApprove, + enabled = !uiState.isLoading && uiState.maxFeeMsat != null, + isLoading = uiState.isLoading, + modifier = Modifier + .weight(1f) + .testTag("PrimaryReceiveApprove") + ) + } + VerticalSpacer(16.dp) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = PrimaryReceiveUiState(amountSats = 20_000uL, maxFeeMsat = 120_000uL, channelFundingSats = 30_000uL), + modifier = Modifier.sheetHeight(), + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewError() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = PrimaryReceiveUiState( + amountSats = 20_000uL, + error = "Your primary node is offline or unreachable.", + ), + modifier = Modifier.sheetHeight(), + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveViewModel.kt new file mode 100644 index 0000000000..c43d1ae586 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveViewModel.kt @@ -0,0 +1,97 @@ +package to.bitkit.ui.screens.wallets.receive + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.di.BgDispatcher +import to.bitkit.repositories.PrimaryNodeRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.utils.primaryErrorMessageRes +import javax.inject.Inject + +@HiltViewModel +class PrimaryReceiveViewModel @Inject constructor( + @ApplicationContext private val context: Context, + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val primaryNodeRepo: PrimaryNodeRepo, + private val walletRepo: WalletRepo, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PrimaryReceiveUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val hasPrimaryNode: Boolean get() = primaryNodeRepo.hasPrimaryNode + + private val _created = MutableSharedFlow(extraBufferCapacity = 1) + val created = _created.asSharedFlow() + + fun requestQuote(amountSats: ULong) { + if (amountSats == 0uL) return + _uiState.update { PrimaryReceiveUiState(amountSats = amountSats, isLoading = true) } + viewModelScope.launch(bgDispatcher) { + primaryNodeRepo.quoteReceive(amountSats) + .onSuccess { quote -> + _uiState.update { + it.copy( + isLoading = false, + maxFeeMsat = quote.feeMsat, + channelFundingSats = quote.fundingSats, + error = null, + ) + } + } + .onFailure { error -> + _uiState.update { + it.copy(isLoading = false, maxFeeMsat = null, error = context.getString(error.primaryErrorMessageRes())) + } + } + } + } + + fun retry() = requestQuote(_uiState.value.amountSats) + + fun approve() { + val state = _uiState.value + val maxFeeMsat = state.maxFeeMsat ?: return + if (state.isLoading) return + _uiState.update { it.copy(isLoading = true, error = null) } + + viewModelScope.launch(bgDispatcher) { + val description = walletRepo.walletState.value.bip21Description + primaryNodeRepo.createInvoice(state.amountSats, description, maxFeeMsat) + .mapCatching { walletRepo.setPrimaryReceiveInvoice(it, state.amountSats, description).getOrThrow() } + .onSuccess { + _uiState.update { it.copy(isLoading = false) } + _created.emit(Unit) + } + .onFailure { error -> + _uiState.update { + it.copy(isLoading = false, maxFeeMsat = null, error = context.getString(error.primaryErrorMessageRes())) + } + } + } + } +} + +@Immutable +data class PrimaryReceiveUiState( + val amountSats: ULong = 0uL, + val maxFeeMsat: ULong? = null, + val channelFundingSats: ULong = 0uL, + val isLoading: Boolean = false, + val error: String? = null, +) { + val maxFeeSats: ULong get() = ((maxFeeMsat ?: 0uL) + 999uL) / 1000uL + val minimumReceivedSats: ULong get() = amountSats - minOf(amountSats, maxFeeSats) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index d90f7de892..2b96dce0ba 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -64,7 +64,6 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R -import to.bitkit.env.Env import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.setClipboardText import to.bitkit.models.NodeLifecycleState @@ -140,7 +139,6 @@ fun ReceiveQrScreen( hasReadyChannels = hasReadyChannels, inboundCapacitySats = readyInboundLiquiditySats, invoiceAmountSats = walletState.bip21AmountSats, - hasPrimaryNode = Env.primaryNode != null, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 358026f2d1..e710e46660 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -27,7 +27,6 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import kotlinx.serialization.Serializable import to.bitkit.R -import to.bitkit.env.Env import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.models.PubkyPublicKeyFormat @@ -71,6 +70,7 @@ fun ReceiveSheet( editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), paymentRequestAmountViewModel: AmountInputViewModel = hiltViewModel(key = "PaymentRequestAmount"), hwReceiveViewModel: HwReceiveViewModel = hiltViewModel(), + primaryReceiveViewModel: PrimaryReceiveViewModel = hiltViewModel(), ) { val wallet = requireNotNull(walletViewModel) val navController = rememberNavController() @@ -124,13 +124,6 @@ fun ReceiveSheet( } } - fun editSoftwareInvoice(tab: ReceiveTab) { - editInvoiceSourceTab = tab - invoiceEditState.beginSoftwareEdit(tab) - resetEditInvoiceAmount() - navController.navigateTo(ReceiveRoute.EditInvoice) - } - LaunchedEffect(Unit) { wallet.resetPreActivityMetadataTagsForCurrentInvoice() wallet.refreshReceiveState() @@ -157,13 +150,21 @@ fun ReceiveSheet( walletState = walletState, lightningState = lightningState, onClickReceiveCjit = { - if (Env.primaryNode != null) { - editSoftwareInvoice(ReceiveTab.SPENDING) + if (primaryReceiveViewModel.hasPrimaryNode) { + editInvoiceSourceTab = ReceiveTab.SPENDING + invoiceEditState.beginSoftwareEdit(ReceiveTab.SPENDING) + resetEditInvoiceAmount() + navController.navigateTo(ReceiveRoute.EditInvoice) } else { navController.navigateTo(ReceiveRoute.TransferUnavailable) } }, - onClickEditInvoice = ::editSoftwareInvoice, + onClickEditInvoice = { + editInvoiceSourceTab = it + invoiceEditState.beginSoftwareEdit(it) + resetEditInvoiceAmount() + navController.navigateTo(ReceiveRoute.EditInvoice) + }, onClickHardwareEditInvoice = { editInvoiceSourceTab = ReceiveTab.HARDWARE invoiceEditState.beginHardwareEdit() @@ -309,6 +310,19 @@ fun ReceiveSheet( onchainOnly = invoiceEditState.isHardwareInvoice, updateOnchainInvoice = wallet::setBip21AmountSats, navigateTransferUnavailable = { navController.navigateTo(ReceiveRoute.TransferUnavailable) }, + navigatePrimaryReceive = { + navController.navigateTo(ReceiveRoute.PrimaryReceiveConfirm(it.toLong())) + }, + ) + } + composableWithDefaultTransitions { backStackEntry -> + val route = backStackEntry.toRoute() + @Suppress("ViewModelForwarding") + PrimaryReceiveConfirmScreen( + amountSats = route.amountSats.toULong(), + viewModel = primaryReceiveViewModel, + onBack = { navController.popBackStack() }, + onCreated = { navController.popBackStack(ReceiveRoute.QR, inclusive = false) }, ) } composableWithDefaultTransitions { @@ -410,6 +424,9 @@ sealed interface ReceiveRoute { @Serializable data object TransferUnavailable : DeepLinkStart + @Serializable + data class PrimaryReceiveConfirm(val amountSats: Long) : InternalOnly + companion object { private val DEEP_LINK_STARTS: List = listOf( QR, diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index a2ec2e6cbd..263dfd4af0 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -215,6 +215,7 @@ fun SettingsScreen( SettingsEvent.LightningConnectionsClick -> navController.navigateTo(Routes.LightningConnections) SettingsEvent.LightningNodeClick -> navController.navigateTo(Routes.NodeInfo) SettingsEvent.ElectrumServerClick -> navController.navigateTo(Routes.ElectrumConfig) + SettingsEvent.PrimaryNodeClick -> navController.navigateTo(Routes.PrimaryNode) SettingsEvent.RgsServerClick -> navController.navigateTo(Routes.RgsServer) SettingsEvent.BackClick -> navController.popBackStack() } @@ -603,6 +604,12 @@ private fun AdvancedTabContent( padding = PaddingValues(top = 16.dp) ) + SettingsButtonRow( + title = stringResource(R.string.primary_node__title), + icon = { SettingsIcon(R.drawable.ic_lightning) }, + onClick = { onEvent(SettingsEvent.PrimaryNodeClick) }, + modifier = Modifier.testTag("PrimaryNodeSettings") + ) SettingsButtonRow( title = stringResource(R.string.settings__adv__lightning_connections), icon = { SettingsIcon(R.drawable.ic_lightning) }, @@ -727,6 +734,7 @@ sealed interface SettingsEvent { data object LightningConnectionsClick : SettingsEvent data object LightningNodeClick : SettingsEvent data object ElectrumServerClick : SettingsEvent + data object PrimaryNodeClick : SettingsEvent data object RgsServerClick : SettingsEvent // Navigation diff --git a/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt new file mode 100644 index 0000000000..3af438ffb1 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt @@ -0,0 +1,195 @@ +package to.bitkit.ui.settings.primaryNode + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import to.bitkit.R +import to.bitkit.ext.getClipboardText +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScanNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun PrimaryNodeScreen( + navController: NavController, + viewModel: PrimaryNodeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val app = appViewModel ?: return + val context = LocalContext.current + + Content( + uiState = uiState, + onBack = { navController.popBackStack() }, + onScan = { app.showScannerSheet { viewModel.onScan(it) } }, + onPaste = { context.getClipboardText()?.let(viewModel::setInput) }, + onChangeInput = viewModel::setInput, + onClickReset = viewModel::resetToDefault, + onClickConnect = viewModel::connect, + ) +} + +@Composable +private fun Content( + uiState: PrimaryNodeUiState = PrimaryNodeUiState(), + onBack: () -> Unit = {}, + onScan: () -> Unit = {}, + onPaste: () -> Unit = {}, + onChangeInput: (String) -> Unit = {}, + onClickReset: () -> Unit = {}, + onClickConnect: () -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.primary_node__title), + onBackClick = onBack, + actions = { ScanNavIcon(onScan) }, + ) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + ) { + VerticalSpacer(16.dp) + BodyM(stringResource(R.string.primary_node__description), color = Colors.White64) + + if (uiState.currentUri.isNotEmpty()) { + VerticalSpacer(24.dp) + Caption13Up(stringResource(R.string.primary_node__current), color = Colors.White64) + VerticalSpacer(8.dp) + BodyS(uiState.currentUri, modifier = Modifier.testTag("PrimaryNodeCurrent")) + VerticalSpacer(4.dp) + BodyM( + text = stringResource( + if (uiState.isConnected) R.string.primary_node__status_connected else R.string.primary_node__status_offline + ), + color = if (uiState.isConnected) Colors.Green else Colors.Red, + modifier = Modifier.testTag(if (uiState.isConnected) "PrimaryNodeOnline" else "PrimaryNodeOffline") + ) + } + + if (uiState.isFundingPaused) { + VerticalSpacer(16.dp) + BodyM( + text = stringResource(R.string.primary_node__funding_paused), + color = Colors.White64, + modifier = Modifier.testTag("PrimaryNodeFundingPaused") + ) + } + + VerticalSpacer(32.dp) + Caption13Up(stringResource(R.string.primary_node__address), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = uiState.input, + onValueChange = onChangeInput, + placeholder = stringResource(R.string.primary_node__address_placeholder), + enabled = !uiState.isLoading, + isError = uiState.error != null, + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + ), + modifier = Modifier + .fillMaxWidth() + .testTag("PrimaryNodeInput") + ) + VerticalSpacer(16.dp) + SecondaryButton( + text = stringResource(R.string.wallet__payment_request_paste), + onClick = onPaste, + enabled = !uiState.isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag("PrimaryNodePaste") + ) + + uiState.error?.let { + VerticalSpacer(16.dp) + BodyM(it, color = Colors.Red, modifier = Modifier.testTag("PrimaryNodeError")) + } + + VerticalSpacer(16.dp) + BodyS(stringResource(R.string.primary_node__trust), color = Colors.White64) + + FillHeight() + VerticalSpacer(16.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + text = stringResource(R.string.settings__es__button_reset), + onClick = onClickReset, + enabled = !uiState.isLoading && !uiState.isDefault, + modifier = Modifier + .weight(1f) + .testTag("PrimaryNodeReset") + ) + PrimaryButton( + text = stringResource(R.string.primary_node__connect), + onClick = onClickConnect, + enabled = !uiState.isLoading && uiState.input.isNotBlank(), + isLoading = uiState.isLoading, + modifier = Modifier + .weight(1f) + .testTag("PrimaryNodeConnect") + ) + } + VerticalSpacer(16.dp) + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = PrimaryNodeUiState( + currentUri = "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c@bore.pub:39101", + input = "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c@bore.pub:39101", + isConnected = true, + isDefault = true, + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeViewModel.kt new file mode 100644 index 0000000000..31e2e06f6e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeViewModel.kt @@ -0,0 +1,107 @@ +package to.bitkit.ui.settings.primaryNode + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.models.PrimaryNode +import to.bitkit.models.Toast +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PrimaryNodeRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.utils.primaryErrorMessageRes +import javax.inject.Inject + +@HiltViewModel +class PrimaryNodeViewModel @Inject constructor( + @ApplicationContext private val context: Context, + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val primaryNodeRepo: PrimaryNodeRepo, + private val lightningRepo: LightningRepo, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PrimaryNodeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch(bgDispatcher) { + combine( + primaryNodeRepo.primaryNode, + primaryNodeRepo.isFundingPaused, + lightningRepo.lightningState, + ) { primary, isPaused, state -> + Triple(primary, isPaused, state.peers.any { it.nodeId == primary?.nodeId && it.isConnected }) + }.collect { (primary, isPaused, isConnected) -> + _uiState.update { + it.copy( + currentUri = primary?.uri.orEmpty(), + input = if (it.hasEdited) it.input else primary?.uri.orEmpty(), + isDefault = primary?.uri == PrimaryNode.parseOrNull(Env.defaultPrimaryNodeUri)?.uri, + isConnected = isConnected, + isFundingPaused = isPaused, + ) + } + } + } + } + + fun setInput(value: String) = _uiState.update { it.copy(input = value.trim(), hasEdited = true, error = null) } + + fun onScan(data: String) { + val primary = PrimaryNode.parseOrNull(data) + if (primary == null) { + _uiState.update { it.copy(error = context.getString(R.string.primary_node__invalid)) } + return + } + setInput(primary.uri) + } + + fun resetToDefault() = setInput(Env.defaultPrimaryNodeUri) + + fun connect() { + val input = _uiState.value.input + if (input.isBlank() || _uiState.value.isLoading) return + _uiState.update { it.copy(isLoading = true, error = null) } + + viewModelScope.launch(bgDispatcher) { + primaryNodeRepo.pair(input) + .onSuccess { + _uiState.update { it.copy(isLoading = false, hasEdited = false) } + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.primary_node__connected), + testTag = "PrimaryNodeConnectedToast", + ) + } + .onFailure { error -> + _uiState.update { + it.copy(isLoading = false, error = context.getString(error.primaryErrorMessageRes())) + } + } + } + } +} + +@Immutable +data class PrimaryNodeUiState( + val currentUri: String = "", + val input: String = "", + val hasEdited: Boolean = false, + val isDefault: Boolean = false, + val isConnected: Boolean = false, + val isFundingPaused: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null, +) diff --git a/app/src/main/java/to/bitkit/ui/utils/PrimaryErrors.kt b/app/src/main/java/to/bitkit/ui/utils/PrimaryErrors.kt new file mode 100644 index 0000000000..3f9f163acb --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/PrimaryErrors.kt @@ -0,0 +1,24 @@ +package to.bitkit.ui.utils + +import androidx.annotation.StringRes +import to.bitkit.R +import to.bitkit.models.PrimaryNodeError +import to.bitkit.repositories.PrimaryReceiveError +import to.bitkit.repositories.PrimaryWalletBusyError +import to.bitkit.repositories.toPrimaryReceiveError + +/** The message shown to the user for a primary node failure. */ +@StringRes +fun Throwable.primaryErrorMessageRes(): Int = when (toPrimaryReceiveError()) { + is PrimaryReceiveError.InsufficientFunds -> R.string.primary_node__error_insufficient_funds + is PrimaryReceiveError.Rejected -> R.string.primary_node__error_rejected + is PrimaryReceiveError.TimedOut -> R.string.primary_node__error_timed_out + is PrimaryReceiveError.AmountTooSmall -> R.string.primary_node__error_amount_too_small + is PrimaryReceiveError.Offline -> R.string.primary_node__error_offline + is PrimaryNodeError.InvalidInput -> R.string.primary_node__invalid + is PrimaryNodeError.AddressUnknown -> R.string.primary_node__address_required + is PrimaryNodeError.ConnectionInUse -> R.string.primary_node__connection_in_use + is PrimaryNodeError.NotConfigured -> R.string.primary_node__not_configured + is PrimaryWalletBusyError -> R.string.primary_node__busy + else -> R.string.primary_node__error_generic +} diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index db7af3980b..c5b67cca5f 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -32,6 +32,7 @@ class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") override val message get() = inner.message ?: super.message val compactType get() = inner.compactType + val nodeException: NodeException? get() = (inner as? LdkException.Node)?.exception sealed interface LdkException { val message: String? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0f1418c67..ac0f1f7247 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -574,6 +574,34 @@ Update Available Please update Bitkit to the latest version for new features and bug fixes! Update\n<accent>Bitkit</accent> + Node URI + publickey@host:port + This public key has no known address. Scan or paste the full publickey@host:port URI. + A change to your channel with your primary node is still pending. Try again once it confirms. + Connect + Primary node connected + Transfer your spending balance to savings and close the channel with your current primary node before choosing another one. + Current primary node + Your primary node opens and grows your spending channel as you receive, so you can send and receive from one balance. + This amount is too small to cover the receive fee. Request a larger amount. + Something went wrong with your primary node. Try again. + Your primary node does not have enough funds for this amount. Try a smaller amount, or receive on-chain instead. + Your primary node is offline or unreachable. Check your connection and try again, or receive on-chain instead. + Your primary node declined this amount. Try a different amount. + Your primary node did not respond. Try again, or receive on-chain instead. + Automatic funding is paused since your last transfer to savings. Transfer to spending to resume it. + Enter a valid node public key or publickey@host:port URI. + No primary node is connected. Set one in Settings › Advanced › Primary node. + Connected + Offline + Primary node + Choose a node you trust. It provides your channel\'s liquidity and is your wallet\'s only Lightning peer. + Spending capacity added + Create Lightning request + Maximum receive fee + You receive at least + Your primary node takes this fee from the payment. Keep Bitkit open until the payment arrives. + Review receive fee Add Link LABEL For example \'Website\' diff --git a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt index 85495fa842..0961142888 100644 --- a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt +++ b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt @@ -13,37 +13,6 @@ class ReceiveLiquidityDecisionTest { inboundCapacitySats = 1_000u, ) - @Test - fun `primary node covers fixed amounts without channels`() { - assertTrue( - ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = false, - inboundCapacitySats = null, - invoiceAmountSats = 5_000u, - hasPrimaryNode = true, - ) - ) - - assertFalse( - ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = false, - inboundCapacitySats = null, - invoiceAmountSats = null, - hasPrimaryNode = true, - ) - ) - } - - @Test - fun `primary node needs no additional liquidity action`() { - assertEquals( - ReceiveAdditionalLiquidityAction.None, - ReceiveLiquidityDecision.additionalLiquidityAction( - defaultAdditionalLiquidityParams.copy(hasPrimaryNode = true) - ), - ) - } - @Test fun `lightning invoice requires ready channel`() { assertFalse( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c9e64a188..9d920b54a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,8 +67,8 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -# Lightning-first test build of coreyphillips/ldk-node feat/lfbw-wallet-client, hosted as a release asset -ldk-node-android = { module = "lfbw:ldk-node-android", version = "0.7.0-rc.77-lfbw.1" } +# Lightning-first build of coreyphillips/ldk-node feat/lfbw-wallet-client (437ee299), hosted as a release asset +ldk-node-android = { module = "lfbw:ldk-node-android", version = "0.7.0-rc.78-lfbw.1" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } From dd2e07d451e9c28cbf18d3b44b8e7b45f277d04d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 04:18:51 +0100 Subject: [PATCH 05/10] feat: transfers and on-chain sends through the primary channel --- .../to/bitkit/repositories/PrimaryNodeRepo.kt | 39 +++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 15 ++- .../transfer/primary/PrimaryTransferScreen.kt | 121 ++++++++++++++++++ .../primary/PrimaryTransferViewModel.kt | 90 +++++++++++++ .../bitkit/ui/screens/wallets/HomeScreen.kt | 2 +- .../usecases/DeriveBalanceStateUseCase.kt | 11 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 51 ++++++++ .../to/bitkit/viewmodels/TransferViewModel.kt | 25 +++- app/src/main/res/values/strings.xml | 7 + 9 files changed, 350 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferViewModel.kt diff --git a/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt index 6de0b5beed..7ca9e182c6 100644 --- a/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt @@ -1,6 +1,9 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -9,12 +12,15 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PrimaryReceiveQuote import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.ext.WatchResult import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.watchUntil import to.bitkit.models.PrimaryNode import to.bitkit.models.PrimaryNodeError import to.bitkit.models.USat @@ -24,6 +30,7 @@ import to.bitkit.utils.LdkError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds /** * Lightning-first wallet operations through the primary node: pairing, just-in-time receives, @@ -49,6 +56,9 @@ class PrimaryNodeRepo @Inject constructor( /** Virtual size each wallet input adds to a funding transaction. */ const val FUNDING_INPUT_VBYTES = 300uL + + /** How long a splice may take to reach its pending funding transaction. */ + private val SPLICE_TIMEOUT = 60.seconds } private val operationMutex = Mutex() @@ -166,20 +176,38 @@ class PrimaryNodeRepo @Inject constructor( return USat(channel.outboundCapacityMsat / 1000uL) - USat(feeRate * FUNDING_BASE_VBYTES) } - /** Pays [amountSats] to [address] from the primary channel by splicing it out. */ - suspend fun sendOnchain(address: String, amountSats: ULong, feeRate: ULong): Result = + /** Pays [amountSats] to [address] from the primary channel by splicing it out; returns the splice txid. */ + suspend fun sendOnchain(address: String, amountSats: ULong, feeRate: ULong): Result = withContext(bgDispatcher) { operationMutex.withLock { runSuspendCatching { val channel = primaryChannel()?.takeIf { it.isUsable } ?: throw PrimaryNodeError.NotConfigured() if (lightningRepo.hasPendingFundingOperation().getOrThrow()) throw PrimaryWalletBusyError() - lightningRepo.spliceOut(channel, address, amountSats, feeRate).getOrThrow() + coroutineScope { + val pending = async(start = CoroutineStart.UNDISPATCHED) { awaitSplice(channel.userChannelId) } + lightningRepo.spliceOut(channel, address, amountSats, feeRate).onFailure { pending.cancel() } + .getOrThrow() + pending.await() + } } } } + private suspend fun awaitSplice(userChannelId: String): String { + val result = lightningRepo.nodeEvents.watchUntil(SPLICE_TIMEOUT) { + when (it) { + is Event.SplicePending if it.userChannelId == userChannelId -> + WatchResult.Complete(Result.success(it.newFundingTxo.txid)) + is Event.SpliceFailed if it.userChannelId == userChannelId -> + WatchResult.Complete(Result.failure(PrimarySpliceFailedError())) + else -> WatchResult.Continue() + } + } ?: throw PrimaryWalletBusyError() + return result.getOrThrow() + } + /** Transfer to Savings: splices the channel's spendable balance out to the wallet and pauses funding. */ - suspend fun transferToSavings(feeRate: ULong): Result = withContext(bgDispatcher) { + suspend fun transferToSavings(feeRate: ULong): Result = withContext(bgDispatcher) { runSuspendCatching { val amount = maxSpliceOutSats(feeRate) if (amount == 0uL) throw PrimaryNodeError.NotConfigured() @@ -188,7 +216,6 @@ class PrimaryNodeRepo @Inject constructor( sendOnchain(address, amount, feeRate).onFailure { settingsStore.update { settings -> settings.copy(primaryFundingPaused = false) } }.getOrThrow() - amount } } @@ -250,6 +277,8 @@ internal fun primaryFundingPlan( ) } +class PrimarySpliceFailedError : AppError("The primary node could not complete the splice.") + class PrimaryWalletBusyError : AppError("A primary channel operation is still pending. Try again once it confirms.") /** Why the primary node could not provide a receive, shown to the user. */ diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2d9d1a027c..3c1919770e 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -135,6 +135,7 @@ import to.bitkit.ui.screens.subscriptions.SubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionsScreen import to.bitkit.ui.screens.transfer.FundingScreen import to.bitkit.ui.screens.transfer.SavingsAdvancedScreen +import to.bitkit.ui.screens.transfer.primary.PrimaryTransferScreen import to.bitkit.ui.screens.transfer.SavingsAvailabilityScreen import to.bitkit.ui.screens.transfer.SavingsConfirmScreen import to.bitkit.ui.screens.transfer.SavingsIntroScreen @@ -1195,7 +1196,7 @@ private fun NavGraphBuilder.home( onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, - onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, + onTransferToSpendingClick = { navController.navigateTo(Routes.PrimaryTransfer) }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, ) @@ -1218,7 +1219,7 @@ private fun NavGraphBuilder.home( navController.navigateToTransferSavingsAvailability() } }, - onTransferFromSavingsClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, + onTransferFromSavingsClick = { navController.navigateTo(Routes.PrimaryTransfer) }, onBackClick = { navController.popBackStack() }, ) } @@ -1678,6 +1679,13 @@ private fun NavGraphBuilder.advancedSettingsSubScreens(navController: NavHostCon deepLinkableComposable { PrimaryNodeScreen(navController) } + deepLinkableComposable { + PrimaryTransferScreen( + onBack = { navController.popBackStack() }, + onDone = { navController.popBackStack(Routes.Home, inclusive = false) }, + onSetUpPrimaryNode = { navController.navigateTo(Routes.PrimaryNode) }, + ) + } deepLinkableComposable { RgsServerScreen(navController) } @@ -2196,6 +2204,9 @@ sealed interface Routes { @Serializable data object PrimaryNode : Routes.DeepLinkable + @Serializable + data object PrimaryTransfer : Routes.DeepLinkable + @Serializable data object RgsServer : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt new file mode 100644 index 0000000000..e2cd87ac0b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt @@ -0,0 +1,121 @@ +package to.bitkit.ui.screens.transfer.primary + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.repositories.PrimaryFundingPlan +import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun PrimaryTransferScreen( + onBack: () -> Unit, + onDone: () -> Unit, + onSetUpPrimaryNode: () -> Unit, + viewModel: PrimaryTransferViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(viewModel) { viewModel.done.collect { onDone() } } + + Content( + uiState = uiState, + onBack = onBack, + onConfirm = viewModel::confirm, + onRetry = viewModel::load, + onSetUpPrimaryNode = onSetUpPrimaryNode, + ) +} + +@Composable +private fun Content( + uiState: PrimaryTransferUiState, + onBack: () -> Unit = {}, + onConfirm: () -> Unit = {}, + onRetry: () -> Unit = {}, + onSetUpPrimaryNode: () -> Unit = {}, +) { + ScreenColumn(modifier = Modifier.testTag("PrimaryTransfer")) { + AppTopBar(titleText = stringResource(R.string.primary_transfer__title), onBackClick = onBack) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + ) { + VerticalSpacer(16.dp) + val plan = uiState.plan + when { + !uiState.hasPrimaryNode -> BodyM(stringResource(R.string.primary_node__not_configured), color = Colors.White64) + plan != null -> { + BalanceHeaderView(sats = plan.amountSats.toLong(), modifier = Modifier.fillMaxWidth()) + VerticalSpacer(32.dp) + Caption13Up(stringResource(R.string.primary_transfer__fee), color = Colors.White64) + VerticalSpacer(8.dp) + MoneySSB(sats = plan.feeSats.toLong(), modifier = Modifier.testTag("PrimaryTransferFee")) + VerticalSpacer(24.dp) + BodyM(stringResource(R.string.primary_transfer__note), color = Colors.White64) + } + !uiState.isLoading && uiState.error == null -> + BodyM(stringResource(R.string.primary_transfer__nothing), color = Colors.White64) + } + uiState.error?.let { + VerticalSpacer(16.dp) + BodyM(it, color = Colors.Red, modifier = Modifier.testTag("PrimaryTransferError")) + } + + FillHeight() + when { + !uiState.hasPrimaryNode -> PrimaryButton( + text = stringResource(R.string.primary_node__title), + onClick = onSetUpPrimaryNode, + modifier = Modifier.testTag("PrimaryTransferSetUp") + ) + plan == null && uiState.error != null -> PrimaryButton( + text = stringResource(R.string.common__retry), + onClick = onRetry, + modifier = Modifier.testTag("PrimaryTransferRetry") + ) + else -> PrimaryButton( + text = stringResource(R.string.primary_transfer__confirm), + onClick = onConfirm, + enabled = plan != null && !uiState.isLoading, + isLoading = uiState.isLoading, + modifier = Modifier.testTag("PrimaryTransferConfirm") + ) + } + VerticalSpacer(16.dp) + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = PrimaryTransferUiState( + plan = PrimaryFundingPlan(amountSats = 48_200uL, feeSats = 600uL, opensChannel = false, isAutomatic = true), + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferViewModel.kt new file mode 100644 index 0000000000..d6d8cfbe4a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferViewModel.kt @@ -0,0 +1,90 @@ +package to.bitkit.ui.screens.transfer.primary + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.di.BgDispatcher +import to.bitkit.models.Toast +import to.bitkit.repositories.PrimaryFundingPlan +import to.bitkit.repositories.PrimaryNodeRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.utils.primaryErrorMessageRes +import javax.inject.Inject + +@HiltViewModel +class PrimaryTransferViewModel @Inject constructor( + @ApplicationContext private val context: Context, + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val primaryNodeRepo: PrimaryNodeRepo, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PrimaryTransferUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _done = MutableSharedFlow(extraBufferCapacity = 1) + val done = _done.asSharedFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch(bgDispatcher) { + if (!primaryNodeRepo.hasPrimaryNode) { + _uiState.update { PrimaryTransferUiState(hasPrimaryNode = false) } + return@launch + } + primaryNodeRepo.fundingPlan() + .onSuccess { plan -> _uiState.update { PrimaryTransferUiState(plan = plan) } } + .onFailure { error -> + _uiState.update { + PrimaryTransferUiState(error = context.getString(error.primaryErrorMessageRes())) + } + } + } + } + + fun confirm() { + val plan = _uiState.value.plan ?: return + if (_uiState.value.isLoading) return + _uiState.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch(bgDispatcher) { + primaryNodeRepo.transferToSpending(plan) + .onSuccess { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.primary_transfer__started_title), + description = context.getString(R.string.primary_transfer__started), + testTag = "PrimaryTransferStartedToast", + ) + _done.emit(Unit) + } + .onFailure { error -> + _uiState.update { + it.copy(isLoading = false, error = context.getString(error.primaryErrorMessageRes())) + } + } + } + } +} + +@Immutable +data class PrimaryTransferUiState( + val hasPrimaryNode: Boolean = true, + val plan: PrimaryFundingPlan? = null, + val isLoading: Boolean = false, + val error: String? = null, +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index d97cc12d32..d2c77ed576 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -313,7 +313,7 @@ fun HomeScreen( appViewModel.showSheet(Sheet.Hardware()) } - Suggestion.LIGHTNING -> appViewModel.showSheet(Sheet.TransferUnavailable) + Suggestion.LIGHTNING -> rootNavController.navigateTo(Routes.PrimaryTransfer) Suggestion.BACK_UP -> { appViewModel.showSheet(Sheet.Backup(BackupRoute.Intro)) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 8e351ba227..eea3a5f5ae 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -20,6 +20,7 @@ import to.bitkit.models.safe import to.bitkit.models.toBalance import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PrimaryNodeRepo import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf @@ -33,6 +34,7 @@ class DeriveBalanceStateUseCase @Inject constructor( private val transferRepo: TransferRepo, private val settingsStore: SettingsStore, private val hwWalletRepo: HwWalletRepo, + private val primaryNodeRepo: PrimaryNodeRepo, ) { suspend operator fun invoke(): Result = withContext(bgDispatcher) { runCatching { @@ -57,7 +59,7 @@ class DeriveBalanceStateUseCase @Inject constructor( channelFundableBalance = channelFundableBalance, totalLightningSats = totalLightningSats, maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), - maxSendOnchainSats = getMaxSendAmount(balanceDetails), + maxSendOnchainSats = maxOf(getMaxSendAmount(balanceDetails), getMaxSpliceOutAmount()), balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), balanceInTransferToSpending = pendingChannelsSats, hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, @@ -109,6 +111,13 @@ class DeriveBalanceStateUseCase @Inject constructor( return toSavingsAmount } + /** The most an on-chain send can take from the primary channel when savings fall short. */ + private suspend fun getMaxSpliceOutAmount(): ULong { + val speed = settingsStore.data.first().defaultTransactionSpeed + val feeRate = lightningRepo.getFeeRateForSpeed(speed).getOrNull() ?: return 0uL + return primaryNodeRepo.maxSpliceOutSats(feeRate) + } + private suspend fun getMaxChannelFundableAmount(fundableBalance: ULong): ULong { if (fundableBalance == 0uL) return 0u diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e39959f5e3..730d82f7a5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -153,6 +153,7 @@ import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.TransportType import to.bitkit.models.USD +import to.bitkit.models.USat import to.bitkit.models.WalletScope import to.bitkit.models.msatFloorOf import to.bitkit.models.safe @@ -206,6 +207,7 @@ import to.bitkit.repositories.PaykitUsdtPaymentRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.PrimaryNodeRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PrivatePaykitPaymentContext @@ -287,6 +289,7 @@ class AppViewModel @Inject constructor( private val keychain: Keychain, private val connectionRecovery: WalletConnectionRecovery, private val lightningRepo: LightningRepo, + private val primaryNodeRepo: PrimaryNodeRepo, private val pendingPaymentRepo: PendingPaymentRepo, private val walletRepo: WalletRepo, private val hwWalletRepo: HwWalletRepo, @@ -6864,6 +6867,54 @@ class AppViewModel @Inject constructor( paymentDeadlineAt: Instant? = null, beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, + ): Result { + val hasPaymentContext = requestId != null || payerIdentity != null || contactPublicKey != null || + paymentDeadlineAt != null + if (!hasPaymentContext && shouldSendFromPrimaryChannel(address, amount)) { + beforeSendAttempt() + val state = _sendUiState.value + val feeRate = lightningRepo.getFeeRateForSpeed(state.speed, state.feeRates).getOrElse { + return Result.failure(it) + } + return primaryNodeRepo.sendOnchain(address, amount, feeRate).map { txid -> + onBroadcast(txid) + OnchainSendOutcome.Accepted(txid, amount) + } + } + return sendOnchainFromSavings( + address = address, + amount = amount, + tags = tags, + requestId = requestId, + payerIdentity = payerIdentity, + contactPublicKey = contactPublicKey, + paymentDeadlineAt = paymentDeadlineAt, + beforeSendAttempt = beforeSendAttempt, + onBroadcast = onBroadcast, + ) + } + + /** True when savings can't cover [amount] and its fee, but the primary channel can. */ + private suspend fun shouldSendFromPrimaryChannel(address: String, amount: ULong): Boolean { + val state = _sendUiState.value + if (state.selectedUtxos != null || primaryNodeRepo.primaryChannel() == null) return false + val spendable = lightningRepo.lightningState.value.balances?.spendableOnchainBalanceSats ?: 0uL + val fee = lightningRepo.calculateTotalFee(amount, address, state.speed, feeRates = state.feeRates).getOrNull() + if (fee != null && USat(amount) + USat(fee) <= spendable) return false + val feeRate = lightningRepo.getFeeRateForSpeed(state.speed, state.feeRates).getOrNull() ?: return false + return primaryNodeRepo.spliceOutFee(amount, feeRate) != null + } + + private suspend fun sendOnchainFromSavings( + address: String, + amount: ULong, + tags: List, + requestId: PaykitPaymentRequestId?, + payerIdentity: String?, + contactPublicKey: String?, + paymentDeadlineAt: Instant?, + beforeSendAttempt: suspend () -> Unit, + onBroadcast: suspend (Txid) -> Unit, ): Result = lightningRepo.sendOnChain( address = address, sats = amount, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 2c9afacd1f..0cb6a8f4c7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -34,8 +34,10 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toUserMessage import to.bitkit.models.Toast +import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PrimaryNodeRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -58,6 +60,7 @@ class TransferViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val transferRepo: TransferRepo, + private val primaryNodeRepo: PrimaryNodeRepo, private val boltzService: BoltzService, private val clock: Clock, ) : ViewModel() { @@ -299,8 +302,11 @@ class TransferViewModel @Inject constructor( channelsToClose = channels } - private fun resolveSavingsTransferMode(): SavingsTransferMode = - if (_savingsSwapState.value.quote != null) SavingsTransferMode.SWAP else SavingsTransferMode.CLOSE + private fun resolveSavingsTransferMode(): SavingsTransferMode = when { + primaryNodeRepo.primaryChannel() != null -> SavingsTransferMode.CLOSE + _savingsSwapState.value.quote != null -> SavingsTransferMode.SWAP + else -> SavingsTransferMode.CLOSE + } /** Closes the channels selected earlier, pending closure */ suspend fun closeSelectedChannels() = closeChannels(channelsToClose) @@ -310,6 +316,9 @@ class TransferViewModel @Inject constructor( val channelsFailedToClose = coroutineScope { channels.map { channel -> async { + if (channel.channelId == primaryNodeRepo.primaryChannel()?.channelId) { + return@async spliceOutPrimaryChannel(channel) + } lightningRepo.closeChannel(channel) .onSuccess { transferRepo.createTransfer( @@ -330,6 +339,18 @@ class TransferViewModel @Inject constructor( return channelsFailedToClose } + /** The primary channel stays open: its balance is spliced out to savings instead. */ + private suspend fun spliceOutPrimaryChannel(channel: ChannelDetails): ChannelDetails? { + val feeRate = lightningRepo.getFeeRateForSpeed(TransactionSpeed.default()).getOrElse { return channel } + return primaryNodeRepo.transferToSavings(feeRate).fold( + onSuccess = { null }, + onFailure = { + Logger.warn("Failed to splice out primary channel '${channel.channelId}'", it, context = TAG) + channel + }, + ) + } + private var coopCloseRetryJob: Job? = null /** Retry to coop close the channel(s) for 30 min */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac0f1f7247..dd5a2eb388 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -602,6 +602,13 @@ You receive at least Your primary node takes this fee from the payment. Keep Bitkit open until the payment arrives. Review receive fee + Transfer + Network fee + Bitkit moves your confirmed savings into your channel with your primary node, so you can spend them instantly. + You have no confirmed savings to transfer yet. + Your savings are moving into your spending balance. + Transfer started + Transfer to spending Add Link LABEL For example \'Website\' From de69f17ce4d555066d8181a22d6588a191509fff Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 04:28:42 +0100 Subject: [PATCH 06/10] feat: primary node translations, journeys and tests --- .../to/bitkit/repositories/LightningRepo.kt | 4 +- .../to/bitkit/services/LightningService.kt | 4 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 +- .../transfer/primary/PrimaryTransferScreen.kt | 12 +++- .../receive/PrimaryReceiveConfirmScreen.kt | 11 +++- .../receive/PrimaryReceiveViewModel.kt | 12 +++- .../settings/primaryNode/PrimaryNodeScreen.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- app/src/main/res/values-ar/strings.xml | 35 +++++++++++ app/src/main/res/values-b+es+419/strings.xml | 35 +++++++++++ app/src/main/res/values-ca/strings.xml | 35 +++++++++++ app/src/main/res/values-cs/strings.xml | 35 +++++++++++ app/src/main/res/values-de/strings.xml | 35 +++++++++++ app/src/main/res/values-el/strings.xml | 35 +++++++++++ app/src/main/res/values-es-rES/strings.xml | 35 +++++++++++ app/src/main/res/values-es/strings.xml | 35 +++++++++++ app/src/main/res/values-fr/strings.xml | 35 +++++++++++ app/src/main/res/values-it/strings.xml | 35 +++++++++++ app/src/main/res/values-nl/strings.xml | 35 +++++++++++ app/src/main/res/values-pl/strings.xml | 35 +++++++++++ app/src/main/res/values-pt-rBR/strings.xml | 35 +++++++++++ app/src/main/res/values-pt/strings.xml | 35 +++++++++++ app/src/main/res/values-ru/strings.xml | 35 +++++++++++ .../java/to/bitkit/models/PrimaryNodeTest.kt | 33 ++++++++++ .../models/ReceiveLiquidityDecisionTest.kt | 32 ++++++++++ .../bitkit/repositories/LightningRepoTest.kt | 1 + .../repositories/PrimaryNodeRepoTest.kt | 63 +++++++++++++++++++ .../to/bitkit/repositories/WalletRepoTest.kt | 1 + .../wallets/receive/EditInvoiceVMTest.kt | 2 +- .../usecases/DeriveBalanceStateUseCaseTest.kt | 1 + .../viewmodels/AppViewModelSendFlowTest.kt | 1 + .../viewmodels/TransferViewModelTest.kt | 1 + changelog.d/next/1728.added.md | 2 +- docs/screens-map.md | 13 ++++ journeys/primary-node/README.md | 14 +++++ journeys/primary-node/pair-primary-node.xml | 16 +++++ .../primary-node/primary-offline-receive.xml | 13 ++++ .../primary-node/receive-primary-quote.xml | 16 +++++ .../primary-node/transfer-through-primary.xml | 16 +++++ 39 files changed, 789 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/to/bitkit/models/PrimaryNodeTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt create mode 100644 journeys/primary-node/README.md create mode 100644 journeys/primary-node/pair-primary-node.xml create mode 100644 journeys/primary-node/primary-offline-receive.xml create mode 100644 journeys/primary-node/receive-primary-quote.xml create mode 100644 journeys/primary-node/transfer-through-primary.xml diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c009ab9adf..fb458fd19e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1319,7 +1319,9 @@ class LightningRepo @Inject constructor( runSuspendCatching { lightningService.receiveViaPrimary(amountSats, description, maxFeeMsat) } } - suspend fun checkPrimaryConnection(nodeId: String): Result = executeWhenNodeRunning("checkPrimaryConnection") { + suspend fun checkPrimaryConnection(nodeId: String): Result = executeWhenNodeRunning( + "checkPrimaryConnection" + ) { runSuspendCatching { lightningService.checkPrimaryConnection(nodeId) }.also { refreshChannelsAndPeers() } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 4c73450d17..2a72aac972 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -37,7 +37,6 @@ import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.Node -import org.lightningdevkit.ldknode.PrimaryReceiveQuote import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.OnchainSendResult @@ -48,6 +47,7 @@ import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.PendingSweepBalance +import org.lightningdevkit.ldknode.PrimaryReceiveQuote import org.lightningdevkit.ldknode.PublicKey import org.lightningdevkit.ldknode.RouteParametersConfig import org.lightningdevkit.ldknode.ScoringFeeParameters @@ -74,13 +74,13 @@ import to.bitkit.ext.uri import to.bitkit.models.OpenChannelResult import to.bitkit.models.PrimaryNode import to.bitkit.models.PrimaryNodeError -import to.bitkit.models.toPeerDetails import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType +import to.bitkit.models.toPeerDetails import to.bitkit.repositories.OnchainPreparedReceipt import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendInput diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 3c1919770e..a5caff42d3 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -135,7 +135,6 @@ import to.bitkit.ui.screens.subscriptions.SubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionsScreen import to.bitkit.ui.screens.transfer.FundingScreen import to.bitkit.ui.screens.transfer.SavingsAdvancedScreen -import to.bitkit.ui.screens.transfer.primary.PrimaryTransferScreen import to.bitkit.ui.screens.transfer.SavingsAvailabilityScreen import to.bitkit.ui.screens.transfer.SavingsConfirmScreen import to.bitkit.ui.screens.transfer.SavingsIntroScreen @@ -146,6 +145,7 @@ import to.bitkit.ui.screens.transfer.external.ExternalConnectionScreen import to.bitkit.ui.screens.transfer.external.ExternalNodeViewModel import to.bitkit.ui.screens.transfer.external.ExternalSuccessScreen import to.bitkit.ui.screens.transfer.external.LnurlChannelScreen +import to.bitkit.ui.screens.transfer.primary.PrimaryTransferScreen import to.bitkit.ui.screens.trezor.TrezorScreen import to.bitkit.ui.screens.wallets.HardwareWalletScreen import to.bitkit.ui.screens.wallets.HomeScreen diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt index e2cd87ac0b..0f08d9d86d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt @@ -65,7 +65,10 @@ private fun Content( VerticalSpacer(16.dp) val plan = uiState.plan when { - !uiState.hasPrimaryNode -> BodyM(stringResource(R.string.primary_node__not_configured), color = Colors.White64) + !uiState.hasPrimaryNode -> BodyM( + stringResource(R.string.primary_node__not_configured), + color = Colors.White64 + ) plan != null -> { BalanceHeaderView(sats = plan.amountSats.toLong(), modifier = Modifier.fillMaxWidth()) VerticalSpacer(32.dp) @@ -114,7 +117,12 @@ private fun Preview() { AppThemeSurface { Content( uiState = PrimaryTransferUiState( - plan = PrimaryFundingPlan(amountSats = 48_200uL, feeSats = 600uL, opensChannel = false, isAutomatic = true), + plan = PrimaryFundingPlan( + amountSats = 48_200uL, + feeSats = 600uL, + opensChannel = false, + isAutomatic = true + ), ), ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveConfirmScreen.kt index b88f8bb21e..aff102d506 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveConfirmScreen.kt @@ -89,7 +89,10 @@ private fun Content( VerticalSpacer(24.dp) Caption13Up(stringResource(R.string.primary_receive__capacity), color = Colors.White64) VerticalSpacer(8.dp) - MoneySSB(sats = uiState.channelFundingSats.toLong(), modifier = Modifier.testTag("PrimaryReceiveCapacity")) + MoneySSB( + sats = uiState.channelFundingSats.toLong(), + modifier = Modifier.testTag("PrimaryReceiveCapacity") + ) } VerticalSpacer(24.dp) BodyM(stringResource(R.string.primary_receive__note), color = Colors.White64) @@ -135,7 +138,11 @@ private fun Preview() { AppThemeSurface { BottomSheetPreview { Content( - uiState = PrimaryReceiveUiState(amountSats = 20_000uL, maxFeeMsat = 120_000uL, channelFundingSats = 30_000uL), + uiState = PrimaryReceiveUiState( + amountSats = 20_000uL, + maxFeeMsat = 120_000uL, + channelFundingSats = 30_000uL + ), modifier = Modifier.sheetHeight(), ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveViewModel.kt index c43d1ae586..5c94a6eec7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveViewModel.kt @@ -53,7 +53,11 @@ class PrimaryReceiveViewModel @Inject constructor( } .onFailure { error -> _uiState.update { - it.copy(isLoading = false, maxFeeMsat = null, error = context.getString(error.primaryErrorMessageRes())) + it.copy( + isLoading = false, + maxFeeMsat = null, + error = context.getString(error.primaryErrorMessageRes()) + ) } } } @@ -77,7 +81,11 @@ class PrimaryReceiveViewModel @Inject constructor( } .onFailure { error -> _uiState.update { - it.copy(isLoading = false, maxFeeMsat = null, error = context.getString(error.primaryErrorMessageRes())) + it.copy( + isLoading = false, + maxFeeMsat = null, + error = context.getString(error.primaryErrorMessageRes()) + ) } } } diff --git a/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt index 3af438ffb1..be7549bc69 100644 --- a/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt @@ -95,7 +95,11 @@ private fun Content( VerticalSpacer(4.dp) BodyM( text = stringResource( - if (uiState.isConnected) R.string.primary_node__status_connected else R.string.primary_node__status_offline + if (uiState.isConnected) { + R.string.primary_node__status_connected + } else { + R.string.primary_node__status_offline + } ), color = if (uiState.isConnected) Colors.Green else Colors.Red, modifier = Modifier.testTag(if (uiState.isConnected) "PrimaryNodeOnline" else "PrimaryNodeOffline") diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 730d82f7a5..ef95baec32 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -207,9 +207,9 @@ import to.bitkit.repositories.PaykitUsdtPaymentRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo -import to.bitkit.repositories.PrimaryNodeRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PrimaryNodeRepo import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyIdentityReadiness diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 045e83ac9b..55dd725e09 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -380,6 +380,41 @@ تحديث متاح يرجى تحديث Bitkit إلى أحدث إصدار للحصول على ميزات جديدة وإصلاحات! تحديث\n<accent>Bitkit</accent> + عنوان URI للعقدة + publickey@host:port + لا يوجد عنوان معروف لهذا المفتاح العام. امسح عنوان URI الكامل publickey@host:port ضوئيًا أو الصقه. + ما زال هناك تغيير معلق في قناتك مع عقدتك الأساسية. حاول مرة أخرى بعد تأكيده. + اتصال + تم اتصال العقدة الأساسية + حوّل رصيد الإنفاق إلى المدخرات وأغلق القناة مع عقدتك الأساسية الحالية قبل اختيار عقدة أخرى. + العقدة الأساسية الحالية + تفتح عقدتك الأساسية قناة الإنفاق الخاصة بك وتوسّعها عند الاستلام، بحيث يمكنك الإرسال والاستلام من رصيد واحد. + هذا المبلغ صغير جدًا لتغطية رسوم الاستلام. اطلب مبلغًا أكبر. + حدث خطأ ما في عقدتك الأساسية. حاول مرة أخرى. + لا تملك عقدتك الأساسية أموالًا كافية لهذا المبلغ. جرّب مبلغًا أصغر أو استلم على السلسلة. + عقدتك الأساسية غير متصلة أو لا يمكن الوصول إليها. تحقق من اتصالك وحاول مرة أخرى، أو استلم على السلسلة. + رفضت عقدتك الأساسية هذا المبلغ. جرّب مبلغًا مختلفًا. + لم تستجب عقدتك الأساسية. حاول مرة أخرى، أو استلم على السلسلة. + التمويل التلقائي متوقف منذ آخر تحويل لك إلى المدخرات. حوّل إلى الإنفاق لاستئنافه. + أدخل مفتاحًا عامًا صالحًا للعقدة أو عنوان URI بصيغة publickey@host:port. + لا توجد عقدة أساسية متصلة. عيّن واحدة في الإعدادات › متقدم › العقدة الأساسية. + متصلة + غير متصلة + العقدة الأساسية + اختر عقدة تثق بها. فهي توفر سيولة قناتك وهي نظير Lightning الوحيد لمحفظتك. + تمت إضافة سعة الإنفاق + إنشاء طلب Lightning + الحد الأقصى لرسوم الاستلام + ستستلم على الأقل + تخصم عقدتك الأساسية هذه الرسوم من الدفعة. أبقِ Bitkit مفتوحًا حتى تصل الدفعة. + مراجعة رسوم الاستلام + تحويل + رسوم الشبكة + ينقل Bitkit مدخراتك المؤكدة إلى قناتك مع عقدتك الأساسية، لتتمكن من إنفاقها فورًا. + ليس لديك مدخرات مؤكدة لتحويلها بعد. + يجري نقل مدخراتك إلى رصيد الإنفاق. + بدأ التحويل + التحويل إلى الإنفاق إضافة علامة التفاصيل حسنًا diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 2e3d9b1f9f..4ac60992ca 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -387,6 +387,41 @@ Actualización Disponible ¡Por favor, actualice Bitkit a la última versión para obtener nuevas funciones y correcciones de errores! Actualice\n<accent>Bitkit</accent> + URI del nodo + publickey@host:port + Esta clave pública no tiene una dirección conocida. Escanee o pegue el URI completo publickey@host:port. + Aún hay un cambio pendiente en su canal con su nodo principal. Inténtelo de nuevo cuando se confirme. + Conectar + Nodo principal conectado + Transfiera su saldo de gasto a ahorro y cierre el canal con su nodo principal actual antes de elegir otro. + Nodo principal actual + Su nodo principal abre y amplía su canal de gasto a medida que recibe, para que pueda enviar y recibir desde un único saldo. + Esta cantidad es demasiado pequeña para cubrir la comisión de recepción. Solicite una cantidad mayor. + Algo salió mal con su nodo principal. Inténtelo de nuevo. + Su nodo principal no tiene fondos suficientes para esta cantidad. Pruebe con una cantidad menor o reciba on-chain. + Su nodo principal está sin conexión o no se puede alcanzar. Compruebe su conexión e inténtelo de nuevo, o reciba on-chain. + Su nodo principal rechazó esta cantidad. Pruebe con otra cantidad. + Su nodo principal no respondió. Inténtelo de nuevo, o reciba on-chain. + La financiación automática está en pausa desde su última transferencia a ahorro. Transfiera a gasto para reanudarla. + Introduzca una clave pública de nodo válida o un URI publickey@host:port. + No hay ningún nodo principal conectado. Configure uno en Ajustes › Avanzado › Nodo principal. + Conectado + Sin conexión + Nodo principal + Elija un nodo de confianza. Proporciona la liquidez de su canal y es el único par Lightning de su billetera. + Capacidad de gasto añadida + Crear solicitud Lightning + Comisión máxima de recepción + Recibe al menos + Su nodo principal descuenta esta comisión del pago. Mantenga Bitkit abierto hasta que llegue el pago. + Revisar comisión de recepción + Transferir + Comisión de red + Bitkit mueve sus ahorros confirmados a su canal con su nodo principal, para que pueda gastarlos al instante. + Aún no tiene ahorros confirmados para transferir. + Sus ahorros se están moviendo a su saldo de gasto. + Transferencia iniciada + Transferir a gasto Añadir enlace Por ejemplo \"Sitio web\" Nota: Cualquier enlace que añada será públicamente visible. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 24dcb34d0a..14db5225d0 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -384,6 +384,41 @@ Actualització disponible Si us plau, actualitzeu Bitkit a la darrera versió per a noves funcionalitats i correccions d\'errors! Actualitza\n<accent>Bitkit</accent> + URI del node + publickey@host:port + Aquesta clau pública no té cap adreça coneguda. Escaneja o enganxa l\'URI complet publickey@host:port. + Encara hi ha un canvi pendent al teu canal amb el node principal. Torna-ho a provar quan es confirmi. + Connecta + Node principal connectat + Transfereix el teu saldo de despesa als estalvis i tanca el canal amb el teu node principal actual abans de triar-ne un altre. + Node principal actual + El teu node principal obre i amplia el teu canal de despesa a mesura que reps, perquè puguis enviar i rebre des d\'un únic saldo. + Aquest import és massa petit per cobrir la comissió de recepció. Sol·licita un import més gran. + Alguna cosa ha anat malament amb el teu node principal. Torna-ho a provar. + El teu node principal no té prou fons per a aquest import. Prova un import més petit o rep on-chain. + El teu node principal està fora de línia o no es pot assolir. Comprova la connexió i torna-ho a provar, o rep on-chain. + El teu node principal ha rebutjat aquest import. Prova un import diferent. + El teu node principal no ha respost. Torna-ho a provar, o rep on-chain. + El finançament automàtic està en pausa des de la darrera transferència als estalvis. Transfereix a despesa per reprendre\'l. + Introdueix una clau pública de node vàlida o un URI publickey@host:port. + No hi ha cap node principal connectat. Defineix-ne un a Configuracions › Avançat › Node principal. + Connectat + Fora de línia + Node principal + Tria un node de confiança. Proporciona la liquiditat del teu canal i és l\'únic parell Lightning de la teva cartera. + Capacitat de despesa afegida + Crea una sol·licitud Lightning + Comissió màxima de recepció + Reps almenys + El teu node principal descompta aquesta comissió del pagament. Mantén Bitkit obert fins que arribi el pagament. + Revisa la comissió de recepció + Transferir + Tarifa de xarxa + Bitkit mou els teus estalvis confirmats al teu canal amb el node principal, perquè els puguis gastar a l\'instant. + Encara no tens estalvis confirmats per transferir. + Els teus estalvis s\'estan movent al teu saldo de despesa. + Transferència iniciada + Transferir a despesa Afegir enllaç Per exemple \'Website\' Nota: Qualsevol enllaç que afegiu serà visible públicament. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f0336264d1..91d52c12d6 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -387,6 +387,41 @@ Aktualizace k dispozici Aktualizujte prosím Bitkit na nejnovější verzi pro nové funkce a opravy chyb! Aktualizovat\n<accent>Bitkit</accent> + URI uzlu + publickey@host:port + Tento veřejný klíč nemá známou adresu. Naskenujte nebo vložte celé URI publickey@host:port. + Změna vašeho kanálu s primárním uzlem stále probíhá. Zkuste to znovu, až bude potvrzena. + Připojit + Primární uzel připojen + Převeďte svůj zůstatek útrat do úspor a uzavřete kanál se stávajícím primárním uzlem, než zvolíte jiný. + Aktuální primární uzel + Váš primární uzel otevírá a rozšiřuje váš kanál útrat, jak přijímáte platby, takže můžete odesílat i přijímat z jednoho zůstatku. + Tato částka je příliš malá na pokrytí poplatku za příjem. Požádejte o vyšší částku. + S vaším primárním uzlem se něco pokazilo. Zkuste to znovu. + Váš primární uzel nemá dostatek prostředků na tuto částku. Zkuste menší částku nebo přijměte on-chain. + Váš primární uzel je offline nebo nedostupný. Zkontrolujte připojení a zkuste to znovu, nebo přijměte on-chain. + Váš primární uzel tuto částku odmítl. Zkuste jinou částku. + Váš primární uzel neodpověděl. Zkuste to znovu nebo přijměte on-chain. + Automatické financování je pozastaveno od vašeho posledního převodu do úspor. Obnovíte ho převodem do útrat. + Zadejte platný veřejný klíč uzlu nebo URI publickey@host:port. + Není připojen žádný primární uzel. Nastavte ho v Nastavení › Pokročilé › Primární uzel. + Připojeno + Offline + Primární uzel + Vyberte uzel, kterému důvěřujete. Poskytuje likviditu vašeho kanálu a je jediným Lightning protějškem vaší peněženky. + Přidaná kapacita útrat + Vytvořit Lightning požadavek + Maximální poplatek za příjem + Obdržíte alespoň + Váš primární uzel si tento poplatek strhne z platby. Nechte Bitkit otevřený, dokud platba nedorazí. + Zkontrolovat poplatek za příjem + Převést + Síťový poplatek + Bitkit přesune vaše potvrzené úspory do kanálu s vaším primárním uzlem, abyste je mohli okamžitě utratit. + Zatím nemáte žádné potvrzené úspory k převodu. + Vaše úspory se přesouvají do vašeho zůstatku útrat. + Převod zahájen + Převod do útrat Vložit odkaz Například „webová stránka“ Poznámka: Jakýkoli odkaz, který přidáte, bude veřejně viditelný. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2a45ff0fe4..66c19cf2a5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -369,6 +369,41 @@ Reisen Update verfügbar Aktualisiere\n<accent>Bitkit</accent> + Node-URI + publickey@host:port + Für diesen Public Key ist keine Adresse bekannt. Scanne oder füge den vollständigen URI publickey@host:port ein. + Eine Änderung an deinem Kanal mit deinem primären Node steht noch aus. Versuche es erneut, sobald sie bestätigt ist. + Verbinden + Primärer Node verbunden + Übertrage dein Ausgabenkonto auf dein Sparkonto und schließe den Kanal zu deinem aktuellen primären Node, bevor du einen anderen wählst. + Aktueller primärer Node + Dein primärer Node öffnet und vergrößert deinen Ausgabenkanal, wenn du Zahlungen empfängst, damit du von einem einzigen Guthaben senden und empfangen kannst. + Dieser Betrag ist zu klein, um die Empfangsgebühr zu decken. Fordere einen größeren Betrag an. + Mit deinem primären Node ist etwas schiefgelaufen. Versuche es erneut. + Dein primärer Node hat nicht genug Guthaben für diesen Betrag. Versuche einen kleineren Betrag oder empfange On-Chain. + Dein primärer Node ist offline oder nicht erreichbar. Prüfe deine Verbindung und versuche es erneut oder empfange On-Chain. + Dein primärer Node hat diesen Betrag abgelehnt. Versuche einen anderen Betrag. + Dein primärer Node hat nicht geantwortet. Versuche es erneut oder empfange On-Chain. + Die automatische Finanzierung ist seit deiner letzten Übertragung auf das Sparkonto pausiert. Übertrage auf das Ausgabenkonto, um sie fortzusetzen. + Gib einen gültigen Public Key eines Nodes oder einen URI publickey@host:port ein. + Es ist kein primärer Node verbunden. Lege einen unter Einstellungen › Fortgeschritten › Primärer Node fest. + Verbunden + Offline + Primärer Node + Wähle einen Node, dem du vertraust. Er stellt die Liquidität deines Kanals bereit und ist der einzige Lightning-Peer deiner Wallet. + Hinzugefügte Ausgabenkapazität + Lightning-Anfrage erstellen + Maximale Empfangsgebühr + Du erhältst mindestens + Dein primärer Node behält diese Gebühr von der Zahlung ein. Lass Bitkit geöffnet, bis die Zahlung eintrifft. + Empfangsgebühr prüfen + Übertragen + Netzgebühr + Bitkit verschiebt dein bestätigtes Sparguthaben in deinen Kanal zu deinem primären Node, damit du es sofort ausgeben kannst. + Du hast noch kein bestätigtes Sparguthaben zum Übertragen. + Dein Sparguthaben wird auf dein Ausgabenkonto verschoben. + Übertragung gestartet + Auf Ausgabenkonto übertragen Link hinzufügen Zum Beispiel \'Webseite\' Beachte: Jeder Link den du hinzufügst ist öffentlich sichtbar. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 8587ae5710..31d5ff6a40 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -380,6 +380,41 @@ Διαθέσιμη ενημέρωση Ενημέρωσε το Bitkit στην τελευταία έκδοση για νέες λειτουργίες και διορθώσεις σφαλμάτων! Ενημέρωσε\n<accent>το Bitkit</accent> + URI κόμβου + publickey@host:port + Αυτό το δημόσιο κλειδί δεν έχει γνωστή διεύθυνση. Σάρωσε ή επικόλλησε ολόκληρο το URI publickey@host:port. + Μια αλλαγή στο κανάλι σου με τον κύριο κόμβο εκκρεμεί ακόμη. Δοκίμασε ξανά μόλις επιβεβαιωθεί. + Σύνδεση + Ο κύριος κόμβος συνδέθηκε + Μετάφερε το υπόλοιπο Δαπανών σου στις Αποταμιεύσεις και κλείσε το κανάλι με τον τρέχοντα κύριο κόμβο πριν επιλέξεις άλλον. + Τρέχων κύριος κόμβος + Ο κύριος κόμβος σου ανοίγει και επεκτείνει το κανάλι δαπανών σου καθώς λαμβάνεις, ώστε να στέλνεις και να λαμβάνεις από ένα ενιαίο υπόλοιπο. + Αυτό το ποσό είναι πολύ μικρό για να καλύψει το τέλος λήψης. Ζήτησε μεγαλύτερο ποσό. + Κάτι πήγε στραβά με τον κύριο κόμβο σου. Δοκίμασε ξανά. + Ο κύριος κόμβος σου δεν έχει αρκετά κεφάλαια για αυτό το ποσό. Δοκίμασε μικρότερο ποσό ή λάβε on-chain. + Ο κύριος κόμβος σου είναι εκτός σύνδεσης ή μη προσβάσιμος. Έλεγξε τη σύνδεσή σου και δοκίμασε ξανά ή λάβε on-chain. + Ο κύριος κόμβος σου απέρριψε αυτό το ποσό. Δοκίμασε διαφορετικό ποσό. + Ο κύριος κόμβος σου δεν απάντησε. Δοκίμασε ξανά ή λάβε on-chain. + Η αυτόματη χρηματοδότηση έχει τεθεί σε παύση από την τελευταία σου μεταφορά στις Αποταμιεύσεις. Μετάφερε στις Δαπάνες για να συνεχιστεί. + Εισήγαγε ένα έγκυρο δημόσιο κλειδί κόμβου ή URI publickey@host:port. + Δεν υπάρχει συνδεδεμένος κύριος κόμβος. Όρισε έναν στις Ρυθμίσεις › Για προχωρημένους › Κύριος κόμβος. + Συνδεδεμένος + Εκτός σύνδεσης + Κύριος κόμβος + Επίλεξε έναν κόμβο που εμπιστεύεσαι. Παρέχει τη ρευστότητα του καναλιού σου και είναι ο μοναδικός Lightning ομότιμος του πορτοφολιού σου. + Προστέθηκε χωρητικότητα δαπανών + Δημιουργία αιτήματος Lightning + Μέγιστο τέλος λήψης + Λαμβάνεις τουλάχιστον + Ο κύριος κόμβος σου παρακρατά αυτό το τέλος από την πληρωμή. Κράτα το Bitkit ανοιχτό μέχρι να φτάσει η πληρωμή. + Έλεγχος τέλους λήψης + Μεταφορά + Τέλος δικτύου + Το Bitkit μετακινεί τις επιβεβαιωμένες Αποταμιεύσεις σου στο κανάλι σου με τον κύριο κόμβο, ώστε να τις ξοδέψεις άμεσα. + Δεν έχεις ακόμη επιβεβαιωμένες Αποταμιεύσεις για μεταφορά. + Οι Αποταμιεύσεις σου μετακινούνται στο υπόλοιπο Δαπανών. + Η μεταφορά ξεκίνησε + Μεταφορά στις Δαπάνες Προσθήκη ετικέτας Λεπτομέρειες ΟΚ diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 9d926c39bd..8f6bb3f6b6 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -386,6 +386,41 @@ Actualización Disponible Por favor, ¡actualice Bitkit a la última versión para nuevas funciones y corregir errores! Actualizar\n<accent>Bitkit</accent> + URI del nodo + publickey@host:port + Esta clave pública no tiene una dirección conocida. Escanee o pegue el URI completo publickey@host:port. + Aún hay un cambio pendiente en su canal con su nodo principal. Inténtelo de nuevo cuando se confirme. + Conectar + Nodo principal conectado + Transfiera su saldo de gasto a ahorro y cierre el canal con su nodo principal actual antes de elegir otro. + Nodo principal actual + Su nodo principal abre y amplía su canal de gasto a medida que recibe, para que pueda enviar y recibir desde un único saldo. + Esta cantidad es demasiado pequeña para cubrir la comisión de recepción. Solicite una cantidad mayor. + Algo salió mal con su nodo principal. Inténtelo de nuevo. + Su nodo principal no tiene fondos suficientes para esta cantidad. Pruebe con una cantidad menor o reciba on-chain. + Su nodo principal está sin conexión o no se puede alcanzar. Compruebe su conexión e inténtelo de nuevo, o reciba on-chain. + Su nodo principal rechazó esta cantidad. Pruebe con otra cantidad. + Su nodo principal no respondió. Inténtelo de nuevo, o reciba on-chain. + La financiación automática está en pausa desde su última transferencia a ahorro. Transfiera a gasto para reanudarla. + Introduzca una clave pública de nodo válida o un URI publickey@host:port. + No hay ningún nodo principal conectado. Configure uno en Ajustes › Avanzado › Nodo principal. + Conectado + Sin conexión + Nodo principal + Elija un nodo de confianza. Proporciona la liquidez de su canal y es el único par Lightning de su monedero. + Capacidad de gasto añadida + Crear solicitud Lightning + Comisión máxima de recepción + Recibe al menos + Su nodo principal descuenta esta comisión del pago. Mantenga Bitkit abierto hasta que llegue el pago. + Revisar comisión de recepción + Transferir + Comisión de red + Bitkit mueve sus ahorros confirmados a su canal con su nodo principal, para que pueda gastarlos al instante. + Aún no tiene ahorros confirmados para transferir. + Sus ahorros se están moviendo a su saldo de gasto. + Transferencia iniciada + Transferir a gasto Añadir enlace Por ejemplo \"Sitio web\" Nota: Cualquier enlace que añada será públicamente visible. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 940f3de425..0e9eea907b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -383,6 +383,41 @@ Actualizar Actualización Disponible Actualizar\n<accent>Bitkit</accent> + URI del nodo + publickey@host:port + Esta clave pública no tiene una dirección conocida. Escanee o pegue el URI completo publickey@host:port. + Aún hay un cambio pendiente en su canal con su nodo principal. Inténtelo de nuevo cuando se confirme. + Conectar + Nodo principal conectado + Transfiera su saldo de gasto a ahorro y cierre el canal con su nodo principal actual antes de elegir otro. + Nodo principal actual + Su nodo principal abre y amplía su canal de gasto a medida que recibe, para que pueda enviar y recibir desde un único saldo. + Esta cantidad es demasiado pequeña para cubrir la comisión de recepción. Solicite una cantidad mayor. + Algo salió mal con su nodo principal. Inténtelo de nuevo. + Su nodo principal no tiene fondos suficientes para esta cantidad. Pruebe con una cantidad menor o reciba on-chain. + Su nodo principal está sin conexión o no se puede alcanzar. Compruebe su conexión e inténtelo de nuevo, o reciba on-chain. + Su nodo principal rechazó esta cantidad. Pruebe con otra cantidad. + Su nodo principal no respondió. Inténtelo de nuevo, o reciba on-chain. + La financiación automática está en pausa desde su última transferencia a ahorro. Transfiera a gasto para reanudarla. + Introduzca una clave pública de nodo válida o un URI publickey@host:port. + No hay ningún nodo principal conectado. Configure uno en Ajustes › Avanzado › Nodo principal. + Conectado + Sin conexión + Nodo principal + Elija un nodo de confianza. Proporciona la liquidez de su canal y es el único par Lightning de su monedero. + Capacidad de gasto añadida + Crear solicitud Lightning + Comisión máxima de recepción + Recibe al menos + Su nodo principal descuenta esta comisión del pago. Mantenga Bitkit abierto hasta que llegue el pago. + Revisar comisión de recepción + Transferir + Comisión de red + Bitkit mueve sus ahorros confirmados a su canal con su nodo principal, para que pueda gastarlos al instante. + Aún no tiene ahorros confirmados para transferir. + Sus ahorros se están moviendo a su saldo de gasto. + Transferencia iniciada + Transferir a gasto Añadir enlace Por ejemplo \"Sitio web\" Nota: Cualquier enlace que añada será públicamente visible. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 61cd95d307..93696b7a18 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -368,6 +368,41 @@ Arrêt en cours Mise à jour disponible Mettre à jour\n<accent>Bitkit</accent> + URI du nœud + publickey@host:port + Cette clé publique n\'a pas d\'adresse connue. Scannez ou collez l\'URI complet publickey@host:port. + Une modification de votre canal avec votre nœud principal est encore en attente. Réessayez une fois qu\'elle sera confirmée. + Connecter + Nœud principal connecté + Transférez votre solde de dépenses vers l\'épargne et fermez le canal avec votre nœud principal actuel avant d\'en choisir un autre. + Nœud principal actuel + Votre nœud principal ouvre et agrandit votre canal de dépenses au fil de vos réceptions, pour que vous puissiez envoyer et recevoir depuis un seul solde. + Ce montant est trop faible pour couvrir les frais de réception. Demandez un montant plus élevé. + Un problème est survenu avec votre nœud principal. Réessayez. + Votre nœud principal n\'a pas assez de fonds pour ce montant. Essayez un montant plus petit, ou recevez on-chain. + Votre nœud principal est hors ligne ou injoignable. Vérifiez votre connexion et réessayez, ou recevez on-chain. + Votre nœud principal a refusé ce montant. Essayez un autre montant. + Votre nœud principal n\'a pas répondu. Réessayez, ou recevez on-chain. + Le financement automatique est en pause depuis votre dernier transfert vers l\'épargne. Transférez vers les dépenses pour le reprendre. + Saisissez une clé publique de nœud valide ou un URI publickey@host:port. + Aucun nœud principal n\'est connecté. Définissez-en un dans Paramètres › Avancé › Nœud principal. + Connecté + Hors ligne + Nœud principal + Choisissez un nœud de confiance. Il fournit la liquidité de votre canal et est le seul pair Lightning de votre portefeuille. + Capacité de dépenses ajoutée + Créer une demande Lightning + Frais de réception maximum + Vous recevez au moins + Votre nœud principal prélève ces frais sur le paiement. Gardez Bitkit ouvert jusqu\'à l\'arrivée du paiement. + Vérifier les frais de réception + Transférer + Frais de réseau + Bitkit déplace votre épargne confirmée vers votre canal avec votre nœud principal, pour que vous puissiez la dépenser instantanément. + Vous n\'avez pas encore d\'épargne confirmée à transférer. + Votre épargne est en cours de transfert vers votre solde de dépenses. + Transfert lancé + Transférer vers les dépenses Ajouter un lien Par exemple, \"site web\". Remarque : tout lien que vous ajoutez sera visible publiquement. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2141ffc645..0eb4b51d98 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -385,6 +385,41 @@ Aggiornamento Disponibile Aggiorna Bitkit all\'ultima versione per le nuove funzioni e la correzione dei bug! Aggiorna\n<accent>Bitkit</accent> + URI del nodo + publickey@host:port + Questa chiave pubblica non ha un indirizzo noto. Scansiona o incolla l\'URI completo publickey@host:port. + Una modifica al tuo canale con il nodo principale è ancora in sospeso. Riprova quando sarà confermata. + Connetti + Nodo principale connesso + Trasferisci il tuo saldo di spesa ai risparmi e chiudi il canale con l\'attuale nodo principale prima di sceglierne un altro. + Nodo principale attuale + Il tuo nodo principale apre e amplia il tuo canale di spesa man mano che ricevi, così puoi inviare e ricevere da un unico saldo. + Questo importo è troppo piccolo per coprire la commissione di ricezione. Richiedi un importo maggiore. + Qualcosa è andato storto con il tuo nodo principale. Riprova. + Il tuo nodo principale non ha fondi sufficienti per questo importo. Prova con un importo minore, oppure ricevi on-chain. + Il tuo nodo principale è offline o irraggiungibile. Controlla la connessione e riprova, oppure ricevi on-chain. + Il tuo nodo principale ha rifiutato questo importo. Prova con un importo diverso. + Il tuo nodo principale non ha risposto. Riprova, oppure ricevi on-chain. + Il finanziamento automatico è in pausa dall\'ultimo trasferimento verso i risparmi. Trasferisci alla spesa per riprenderlo. + Inserisci una chiave pubblica di nodo valida o un URI publickey@host:port. + Nessun nodo principale è connesso. Impostane uno in Impostazioni › Avanzate › Nodo principale. + Connesso + Offline + Nodo principale + Scegli un nodo di cui ti fidi. Fornisce la liquidità del tuo canale ed è l\'unico peer Lightning del tuo wallet. + Capacità di spesa aggiunta + Crea richiesta Lightning + Commissione massima di ricezione + Ricevi almeno + Il tuo nodo principale trattiene questa commissione dal pagamento. Tieni Bitkit aperto finché il pagamento non arriva. + Verifica commissione di ricezione + Trasferisci + Commissione di rete + Bitkit sposta i tuoi risparmi confermati nel tuo canale con il nodo principale, così puoi spenderli all\'istante. + Non hai ancora risparmi confermati da trasferire. + I tuoi risparmi si stanno spostando nel tuo saldo di spesa. + Trasferimento avviato + Trasferisci alla spesa Aggiungi Link Per esempio \'Sito web\' Nota: qualsiasi link aggiunto sarà visibile pubblicamente. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0f16a7c531..7ba3394eea 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -384,6 +384,41 @@ Update beschikbaar Werk Bitkit bij naar de nieuwste versie voor nieuwe functies en bugfixes! Update\n<accent>Bitkit</accent> + Node-URI + publickey@host:port + Voor deze publieke sleutel is geen adres bekend. Scan of plak de volledige URI publickey@host:port. + Een wijziging aan je kanaal met je primaire node is nog in behandeling. Probeer het opnieuw zodra die bevestigd is. + Verbinden + Primaire node verbonden + Maak je bestedingssaldo over naar spaargeld en sluit het kanaal met je huidige primaire node voordat je een andere kiest. + Huidige primaire node + Je primaire node opent en vergroot je bestedingskanaal terwijl je ontvangt, zodat je vanuit één saldo kunt verzenden en ontvangen. + Dit bedrag is te klein om de ontvangstvergoeding te dekken. Vraag een groter bedrag aan. + Er is iets misgegaan met je primaire node. Probeer het opnieuw. + Je primaire node heeft niet genoeg saldo voor dit bedrag. Probeer een kleiner bedrag of ontvang on-chain. + Je primaire node is offline of onbereikbaar. Controleer je verbinding en probeer het opnieuw, of ontvang on-chain. + Je primaire node heeft dit bedrag geweigerd. Probeer een ander bedrag. + Je primaire node heeft niet gereageerd. Probeer het opnieuw of ontvang on-chain. + Automatische financiering is gepauzeerd sinds je laatste overboeking naar spaargeld. Boek over naar bestedingssaldo om het te hervatten. + Voer een geldige publieke sleutel van een node of een URI publickey@host:port in. + Er is geen primaire node verbonden. Stel er een in via Instellingen › Geavanceerd › Primaire node. + Verbonden + Offline + Primaire node + Kies een node die je vertrouwt. Die levert de liquiditeit van je kanaal en is de enige Lightning-peer van je wallet. + Toegevoegde bestedingscapaciteit + Lightning-verzoek aanmaken + Maximale ontvangstvergoeding + Je ontvangt minimaal + Je primaire node houdt deze vergoeding in op de betaling. Houd Bitkit open tot de betaling binnen is. + Ontvangstvergoeding controleren + Overboeken + Netwerkvergoeding + Bitkit verplaatst je bevestigde spaargeld naar je kanaal met je primaire node, zodat je het direct kunt uitgeven. + Je hebt nog geen bevestigd spaargeld om over te boeken. + Je spaargeld wordt verplaatst naar je bestedingssaldo. + Overboeking gestart + Overboeken naar besteden Link Toevoegen Bijvoorbeeld \'Website\' Let op: Iedere link die u toevoegt is openbaar beschikbaar. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d1a5a29132..7c212f1730 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -368,6 +368,41 @@ Zatrzymywanie Aktualizacja dostępna Uaktualnij\n<accent>Bitkit</accent> + URI węzła + publickey@host:port + Ten klucz publiczny nie ma znanego adresu. Zeskanuj lub wklej pełny URI publickey@host:port. + Zmiana w Twoim kanale z węzłem głównym nadal oczekuje. Spróbuj ponownie po jej potwierdzeniu. + Połącz + Węzeł główny połączony + Przenieś saldo wydatków do oszczędności i zamknij kanał z obecnym węzłem głównym, zanim wybierzesz inny. + Obecny węzeł główny + Twój węzeł główny otwiera i powiększa Twój kanał wydatków w miarę otrzymywania płatności, dzięki czemu możesz wysyłać i odbierać z jednego salda. + Ta kwota jest zbyt mała, aby pokryć opłatę za odbiór. Poproś o większą kwotę. + Coś poszło nie tak z Twoim węzłem głównym. Spróbuj ponownie. + Twój węzeł główny nie ma wystarczających środków na tę kwotę. Spróbuj mniejszej kwoty lub odbierz on-chain. + Twój węzeł główny jest offline lub niedostępny. Sprawdź połączenie i spróbuj ponownie albo odbierz on-chain. + Twój węzeł główny odrzucił tę kwotę. Spróbuj innej kwoty. + Twój węzeł główny nie odpowiedział. Spróbuj ponownie lub odbierz on-chain. + Automatyczne zasilanie jest wstrzymane od Twojego ostatniego przelewu do oszczędności. Prześlij środki do wydatków, aby je wznowić. + Wprowadź prawidłowy klucz publiczny węzła lub URI publickey@host:port. + Żaden węzeł główny nie jest połączony. Ustaw go w Ustawienia › Zaawansowane › Węzeł główny. + Połączono + Offline + Węzeł główny + Wybierz węzeł, któremu ufasz. Zapewnia płynność Twojego kanału i jest jedynym partnerem Lightning Twojego portfela. + Dodana pojemność wydatków + Utwórz żądanie Lightning + Maksymalna opłata za odbiór + Otrzymasz co najmniej + Twój węzeł główny pobiera tę opłatę z płatności. Pozostaw Bitkit otwarty, dopóki płatność nie dotrze. + Sprawdź opłatę za odbiór + Prześlij + Opłata sieciowa + Bitkit przenosi Twoje potwierdzone oszczędności do kanału z węzłem głównym, abyś mógł je wydać natychmiast. + Nie masz jeszcze potwierdzonych oszczędności do przesłania. + Twoje oszczędności są przenoszone do salda wydatków. + Transfer rozpoczęty + Prześlij do wydatków Dodaj link Na przykład \'Strona internetowa\' Uwaga: Każdy dodany link będzie widoczny publicznie. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0266a4b1e4..e56599a78e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -388,6 +388,41 @@ Atualização disponível Atualize a Bitkit para obter novos recursos e correções de bugs! Atualizar\n<accent>Bitkit</accent> + URI do nó + publickey@host:port + Esta chave pública não tem um endereço conhecido. Leia ou cole o URI completo publickey@host:port. + Ainda há uma alteração pendente no canal com seu nó principal. Tente novamente quando for confirmada. + Conectar + Nó principal conectado + Transfira seu saldo de gastos para a poupança e feche o canal com seu nó principal atual antes de escolher outro. + Nó principal atual + Seu nó principal abre e aumenta seu canal de gastos à medida que você recebe, para que possa enviar e receber a partir de um único saldo. + Este valor é pequeno demais para cobrir a taxa de recebimento. Solicite um valor maior. + Algo deu errado com seu nó principal. Tente novamente. + Seu nó principal não tem fundos suficientes para este valor. Tente um valor menor ou receba on-chain. + Seu nó principal está offline ou inacessível. Verifique sua conexão e tente novamente, ou receba on-chain. + Seu nó principal recusou este valor. Tente um valor diferente. + Seu nó principal não respondeu. Tente novamente, ou receba on-chain. + O financiamento automático está em pausa desde sua última transferência para a poupança. Transfira para gastos para retomá-lo. + Insira uma chave pública de nó válida ou um URI publickey@host:port. + Nenhum nó principal está conectado. Defina um em Ajustes › Avançado › Nó principal. + Conectado + Offline + Nó principal + Escolha um nó em que confie. Ele fornece a liquidez dseu canal e é o único par Lightning dsua carteira. + Capacidade de gastos adicionada + Criar pedido Lightning + Taxa máxima de recebimento + Você recebe pelo menos + Seu nó principal retira esta taxa do pagamento. Mantenha o Bitkit aberto até o pagamento chegar. + Revisar taxa de recebimento + Transferir + Taxa da rede + O Bitkit move sua poupança confirmada para seu canal com seu nó principal, para que possa gastá-la instantaneamente. + Ainda não tem poupança confirmada para transferir. + Sua poupança está sendo movida para seu saldo de gastos. + Transferência iniciada + Transferir para gastos Adicionar link Por exemplo, \"Website\". Observação: Qualquer link que você adicionar ficará visível publicamente. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c9f113920f..e3155d3512 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -351,6 +351,41 @@ Escanear QR Atualização disponível Atualizar\n<accent>Bitkit</accent> + URI do nó + publickey@host:port + Esta chave pública não tem um endereço conhecido. Leia ou cole o URI completo publickey@host:port. + Ainda há uma alteração pendente no seu canal com o seu nó principal. Tente novamente quando for confirmada. + Conectar + Nó principal conectado + Transfira o seu saldo de gastos para a poupança e feche o canal com o seu nó principal atual antes de escolher outro. + Nó principal atual + O seu nó principal abre e aumenta o seu canal de gastos à medida que você recebe, para que possa enviar e receber a partir de um único saldo. + Este valor é pequeno demais para cobrir a taxa de recebimento. Solicite um valor maior. + Algo correu mal com o seu nó principal. Tente novamente. + O seu nó principal não tem fundos suficientes para este valor. Tente um valor menor ou receba on-chain. + O seu nó principal está offline ou inacessível. Verifique a sua conexão e tente novamente, ou receba on-chain. + O seu nó principal recusou este valor. Tente um valor diferente. + O seu nó principal não respondeu. Tente novamente, ou receba on-chain. + O financiamento automático está em pausa desde a sua última transferência para a poupança. Transfira para gastos para retomá-lo. + Insira uma chave pública de nó válida ou um URI publickey@host:port. + Nenhum nó principal está conectado. Defina um em Ajustes › Avançado › Nó principal. + Conectado + Offline + Nó principal + Escolha um nó em que confie. Ele fornece a liquidez do seu canal e é o único par Lightning da sua carteira. + Capacidade de gastos adicionada + Criar pedido Lightning + Taxa máxima de recebimento + Você recebe pelo menos + O seu nó principal retira esta taxa do pagamento. Mantenha o Bitkit aberto até o pagamento chegar. + Rever taxa de recebimento + Transferir + Taxa da rede + O Bitkit move a sua poupança confirmada para o seu canal com o seu nó principal, para que possa gastá-la instantaneamente. + Ainda não tem poupança confirmada para transferir. + A sua poupança está a ser movida para o seu saldo de gastos. + Transferência iniciada + Transferir para gastos Adicionar Tag Detalhes Criar Perfil diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index cd61bf299f..5dc85d2cd5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -398,6 +398,41 @@ Доступно Обновление Пожалуйста, обновите Bitkit до последней версии для новых функций и исправления ошибок! Обновите\n<accent>Bitkit</accent> + URI ноды + publickey@host:port + Для этого публичного ключа адрес неизвестен. Отсканируйте или вставьте полный URI publickey@host:port. + Изменение вашего канала с основной нодой всё ещё в обработке. Повторите попытку после подтверждения. + Подключиться + Основная нода подключена + Переведите баланс расходов в сбережения и закройте канал с текущей основной нодой, прежде чем выбрать другую. + Текущая основная нода + Ваша основная нода открывает и расширяет ваш канал расходов по мере получения платежей, так что вы можете отправлять и получать с одного баланса. + Эта сумма слишком мала, чтобы покрыть комиссию за получение. Запросите большую сумму. + Что-то пошло не так с вашей основной нодой. Повторите попытку. + У вашей основной ноды недостаточно средств для этой суммы. Попробуйте меньшую сумму или получите on-chain. + Ваша основная нода не в сети или недоступна. Проверьте подключение и повторите попытку или получите on-chain. + Ваша основная нода отклонила эту сумму. Попробуйте другую сумму. + Ваша основная нода не ответила. Повторите попытку или получите on-chain. + Автоматическое пополнение приостановлено с момента вашего последнего перевода в сбережения. Переведите в расходы, чтобы возобновить его. + Введите действительный публичный ключ ноды или URI publickey@host:port. + Основная нода не подключена. Задайте её в Настройки › Дополнительно › Основная нода. + Подключено + Не в сети + Основная нода + Выберите ноду, которой доверяете. Она обеспечивает ликвидность вашего канала и является единственным Lightning-пиром вашего кошелька. + Добавленная ёмкость расходов + Создать Lightning-запрос + Максимальная комиссия за получение + Вы получите не менее + Ваша основная нода удерживает эту комиссию из платежа. Не закрывайте Bitkit, пока платёж не поступит. + Проверка комиссии за получение + Перевести + Комиссия сети + Bitkit переводит ваши подтверждённые сбережения в канал с вашей основной нодой, чтобы вы могли тратить их мгновенно. + У вас пока нет подтверждённых сбережений для перевода. + Ваши сбережения переводятся на баланс расходов. + Перевод начат + Перевод в расходы Добавить Ссылку Например, \'Сайт\' Примечание: Любая ссылка, которую вы добавите, будет видна всем. diff --git a/app/src/test/java/to/bitkit/models/PrimaryNodeTest.kt b/app/src/test/java/to/bitkit/models/PrimaryNodeTest.kt new file mode 100644 index 0000000000..aa052b269d --- /dev/null +++ b/app/src/test/java/to/bitkit/models/PrimaryNodeTest.kt @@ -0,0 +1,33 @@ +package to.bitkit.models + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class PrimaryNodeTest { + + private val nodeId = "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c" + + @Test + fun `parses a node uri and normalizes it`() { + val primary = PrimaryNode.parse("lightning:${nodeId.uppercase()}@Bore.pub:39101") + + assertEquals(PrimaryNode(nodeId, "bore.pub:39101"), primary) + assertEquals("$nodeId@bore.pub:39101", primary.uri) + } + + @Test + fun `parses a bare public key without an address`() { + assertEquals(PrimaryNode(nodeId), PrimaryNode.parse(nodeId)) + } + + @Test + fun `rejects invalid keys and addresses`() { + assertFailsWith { PrimaryNode.parse("02" + "0".repeat(64)) } + assertFailsWith { PrimaryNode.parse("$nodeId@host:0") } + assertFailsWith { PrimaryNode.parse("$nodeId@host:9735/path") } + assertFailsWith { PrimaryNode.parse("$nodeId @host:9735") } + assertNull(PrimaryNode.parseOrNull("not a node")) + } +} diff --git a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt index 0961142888..7ea28de846 100644 --- a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt +++ b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt @@ -94,4 +94,36 @@ class ReceiveLiquidityDecisionTest { private fun additionalLiquidityAction(params: ReceiveAdditionalLiquidityParams) = ReceiveLiquidityDecision.additionalLiquidityAction(params) + + @Test + fun `receive above inbound goes through the primary when one is set`() { + val params = defaultAdditionalLiquidityParams.copy(hasPrimaryNode = true) + + assertEquals( + ReceiveAdditionalLiquidityAction.PrimaryReceive, + ReceiveLiquidityDecision.additionalLiquidityAction(params) + ) + assertEquals( + ReceiveAdditionalLiquidityAction.PrimaryReceive, + ReceiveLiquidityDecision.additionalLiquidityAction(params.copy(inboundCapacitySats = 0u)), + ) + assertEquals( + ReceiveAdditionalLiquidityAction.PrimaryReceive, + ReceiveLiquidityDecision.additionalLiquidityAction(params.copy(source = ReceiveLiquiditySource.AUTO)), + ) + } + + @Test + fun `primary is not asked for a receive that fits or a savings invoice`() { + val params = defaultAdditionalLiquidityParams.copy(hasPrimaryNode = true) + + assertEquals( + ReceiveAdditionalLiquidityAction.None, + ReceiveLiquidityDecision.additionalLiquidityAction(params.copy(inboundCapacitySats = 20_000u)), + ) + assertEquals( + ReceiveAdditionalLiquidityAction.None, + ReceiveLiquidityDecision.additionalLiquidityAction(params.copy(source = ReceiveLiquiditySource.SAVINGS)), + ) + } } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 5fabef374f..3c5ccef805 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -229,6 +229,7 @@ class LightningRepoTest : BaseUnitTest() { activityRepo = mock(), hwWalletRepo = hardware, connectionRecovery = connectionRecoveryFixture(settingsStore), + primaryNodeRepo = mock(), ) } diff --git a/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt new file mode 100644 index 0000000000..429463b429 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt @@ -0,0 +1,63 @@ +package to.bitkit.repositories + +import org.junit.Test +import org.lightningdevkit.ldknode.NodeException +import to.bitkit.utils.LdkError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PrimaryNodeRepoTest { + + @Test + fun `funding plan moves confirmed savings minus the mining fee`() { + val plan = primaryFundingPlan(spendableSats = 50_000uL, inputs = 1uL, feeRate = 2uL, hasChannel = true) + + requireNotNull(plan) + assertEquals(1_200uL, plan.feeSats) + assertEquals(48_800uL, plan.amountSats) + assertFalse(plan.opensChannel) + assertTrue(plan.isAutomatic) + } + + @Test + fun `funding opens a channel when the wallet has none`() { + val plan = primaryFundingPlan(spendableSats = 50_000uL, inputs = 1uL, feeRate = 1uL, hasChannel = false) + + assertTrue(requireNotNull(plan).opensChannel) + } + + @Test + fun `automatic funding waits below the minimum or above the fee limit`() { + val small = primaryFundingPlan(spendableSats = 24_999uL, inputs = 1uL, feeRate = 1uL, hasChannel = true) + val expensive = primaryFundingPlan(spendableSats = 30_000uL, inputs = 1uL, feeRate = 3uL, hasChannel = true) + + assertFalse(requireNotNull(small).isAutomatic) + assertFalse(requireNotNull(expensive).isAutomatic) + } + + @Test + fun `funding plan is empty when nothing can be moved`() { + assertNull(primaryFundingPlan(spendableSats = 0uL, inputs = 0uL, feeRate = 1uL, hasChannel = true)) + assertNull(primaryFundingPlan(spendableSats = 500uL, inputs = 1uL, feeRate = 1uL, hasChannel = true)) + } + + @Test + fun `liquidity errors map to what the user is told`() { + fun map(e: NodeException) = LdkError(e).toPrimaryReceiveError() + + assertIs(map(NodeException.LiquiditySourceInsufficientFunds(""))) + assertIs(map(NodeException.LiquidityRequestRejected(""))) + assertIs(map(NodeException.LiquidityRequestTimedOut(""))) + assertIs(map(NodeException.LiquidityReceiveAmountTooSmall(""))) + } + + @Test + fun `an unreachable primary is reported as offline`() { + val error = LdkError(NodeException.ConnectionFailed("")).toPrimaryReceiveError() + + assertIs(error) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index a521f0b0fb..91b620ad17 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -178,6 +178,7 @@ class WalletRepoTest : BaseUnitTest() { activityRepo = activityRepo, hwWalletRepo = hwWalletRepo, connectionRecovery = connectionRecovery, + primaryNodeRepo = mock(), ) private fun lightningInvoice(bolt11: String, isExpired: Boolean = false) = LightningInvoice( diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt index 0fc77baca2..556f0a4e32 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt @@ -23,7 +23,7 @@ class EditInvoiceVMTest : BaseUnitTest() { @Before fun setUp() = runBlocking { whenever(walletRepo.inboundLiquiditySats()).thenReturn(1_000u) - sut = EditInvoiceVM(walletRepo) + sut = EditInvoiceVM(walletRepo, mock()) } @Test diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index 342da8dd1d..669eeb4c6e 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -60,6 +60,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { transferRepo = transferRepo, settingsStore = settingsStore, hwWalletRepo = hwWalletRepo, + primaryNodeRepo = mock(), ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a39c3b3de4..fa56a9bff8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -912,6 +912,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { keychain = keychain, connectionRecovery = connectionRecovery, lightningRepo = lightningRepo, + primaryNodeRepo = mock(), pendingPaymentRepo = pendingPaymentRepo, walletRepo = walletRepo, hwWalletRepo = hwWalletRepo, diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 9c7e581058..abd9e13fc1 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -72,6 +72,7 @@ class TransferViewModelTest : BaseUnitTest() { lightningRepo = lightningRepo, walletRepo = walletRepo, transferRepo = transferRepo, + primaryNodeRepo = mock(), boltzService = boltzService, clock = clock, ) diff --git a/changelog.d/next/1728.added.md b/changelog.d/next/1728.added.md index f6b7bd3de9..6e809af9f1 100644 --- a/changelog.d/next/1728.added.md +++ b/changelog.d/next/1728.added.md @@ -1 +1 @@ -Lightning can receive and transfer to spending through a primary node that opens or grows a channel just in time. +Receive Lightning from an empty wallet, send on-chain from spending, and move funds between savings and spending through a primary node you can change in Settings. diff --git a/docs/screens-map.md b/docs/screens-map.md index 7dfb7bf5df..1d9fc40d48 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -151,6 +151,12 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | +## ui/screens/transfer/primary + +| Android | Figma | +| - | - | +| PrimaryTransferScreen.kt | `todo` | + ## ui/screens/trezor | Android | Figma | @@ -180,6 +186,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | | EditInvoiceScreen.kt | Send (Contact) (Lightning) › Edit invoice populated | +| PrimaryReceiveConfirmScreen.kt | `todo` | | ReceiveQrScreen.kt | Receive Lightning Active › Receive Auto (Unified QR) | | TransferUnavailableScreen.kt | `todo` | @@ -320,6 +327,12 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | PinPromptScreen.kt | Settings > Security › PIN Change Enter Current | | PinResultScreen.kt | Security › Create passcode | +## ui/settings/primaryNode + +| Android | Figma | +| - | - | +| PrimaryNodeScreen.kt | `todo` | + ## ui/settings/quickPay | Android | Figma | diff --git a/journeys/primary-node/README.md b/journeys/primary-node/README.md new file mode 100644 index 0000000000..94cb630518 --- /dev/null +++ b/journeys/primary-node/README.md @@ -0,0 +1,14 @@ +# Primary node + +Lightning-first wallet flows through a primary node: a Lightning node that opens a channel just in time for the +first receive, grows it by splicing, and funds on-chain sends from it by splicing out. + +Preconditions for every journey here: + +- A regtest build with `PRIMARY_NODE_URI` set in `local.properties` to a primary on the same chain, for example a + Beignet node with its liquidity provider on (`liquidityProvider`, `waiveClientReserve`) and a funded on-chain wallet. +- Regtest fees at 1 sat/vB: a Beignet primary opens just-in-time channels at a fixed 253 sat/kw commitment fee + (coreyphillips/beignet#1873), which ldk-node refuses once its fee floor is higher. +- A second Lightning wallet with a channel into the primary pays the invoices and receives the sends. + +Both apps run these journeys with the same file names. diff --git a/journeys/primary-node/pair-primary-node.xml b/journeys/primary-node/pair-primary-node.xml new file mode 100644 index 0000000000..dc396920b0 --- /dev/null +++ b/journeys/primary-node/pair-primary-node.xml @@ -0,0 +1,16 @@ + + + Proves the primary node can be seen, changed and reset in Settings, and that a failed pairing keeps the previous + primary. Precondition: see README.md; a fresh wallet with no channel, so changing the primary is allowed. + + + Open Settings, the Advanced tab, and tap "Primary node" (testTag "PrimaryNodeSettings") + Verify "PrimaryNodeCurrent" shows the default primary URI and "PrimaryNodeOnline" is shown + Clear "PrimaryNodeInput", type a valid public key with an unreachable address such as 127.0.0.1:1, and tap "PrimaryNodeConnect" + Verify "PrimaryNodeError" says the primary node is offline or unreachable, and "PrimaryNodeCurrent" still shows the default primary + Type "not a node" into "PrimaryNodeInput" and tap "PrimaryNodeConnect" + Verify "PrimaryNodeError" asks for a valid node public key or URI + Tap "PrimaryNodeReset", then "PrimaryNodeConnect" + Verify the "Primary node connected" toast and "PrimaryNodeOnline" + + diff --git a/journeys/primary-node/primary-offline-receive.xml b/journeys/primary-node/primary-offline-receive.xml new file mode 100644 index 0000000000..467028f7e3 --- /dev/null +++ b/journeys/primary-node/primary-offline-receive.xml @@ -0,0 +1,13 @@ + + + Proves a receive that needs the primary tells the user when the primary is unreachable, instead of a spinner. + Precondition: see README.md; stop the primary node before the quote step and start it again for the retry. + + + Stop the primary node + Open Receive, switch to the Spending tab, enter an amount above the channel's inbound capacity and continue + Verify "PrimaryReceiveError" says the primary node is offline or unreachable and "PrimaryReceiveRetry" is shown + Start the primary node, wait for the wallet to reconnect, and tap "PrimaryReceiveRetry" + Verify "PrimaryReceiveFee" is shown and "PrimaryReceiveApprove" is enabled + + diff --git a/journeys/primary-node/receive-primary-quote.xml b/journeys/primary-node/receive-primary-quote.xml new file mode 100644 index 0000000000..89b933b4ce --- /dev/null +++ b/journeys/primary-node/receive-primary-quote.xml @@ -0,0 +1,16 @@ + + + Proves a receive the channel cannot take goes through the primary only after the user approves its fee: on an + empty wallet the primary opens a channel, and above the channel's inbound it splices more in. Precondition: see + README.md. + + + Open Receive, switch to the Spending tab and tap the Lightning receive button + Enter 20000 sats and continue + Verify the "Review receive fee" screen (testTag "PrimaryReceiveConfirm") shows "PrimaryReceiveFee" and "PrimaryReceiveMin" + Tap "PrimaryReceiveApprove" and verify the receive QR shows a Lightning invoice for 20000 sats + Pay the invoice from the second wallet and verify the received sheet and a spending balance of at least the minimum shown + Open Receive again, edit the Spending invoice to an amount above the channel's inbound capacity and continue + Approve the fee, pay the invoice from the second wallet, and verify the payment arrives without a second channel in Settings › Advanced › Lightning connections + + diff --git a/journeys/primary-node/transfer-through-primary.xml b/journeys/primary-node/transfer-through-primary.xml new file mode 100644 index 0000000000..4d578701e3 --- /dev/null +++ b/journeys/primary-node/transfer-through-primary.xml @@ -0,0 +1,16 @@ + + + Proves transfers move funds between savings and the primary channel without closing it, and that an on-chain send + larger than savings is paid from the channel. Precondition: see README.md; a wallet with a channel with the primary. + + + Open Spending and tap Transfer To Savings, then confirm the transfer + Verify the transfer completes, Settings › Advanced › Lightning connections still lists the channel, and the savings balance grew + Open Settings › Advanced › Primary node and verify "PrimaryNodeFundingPaused" is shown + Mine a block, open Savings and tap Transfer To Spending + Verify the "PrimaryTransfer" screen shows the amount and "PrimaryTransferFee", then tap "PrimaryTransferConfirm" + Verify the "Transfer started" toast, and after a block that the spending balance grew and "PrimaryNodeFundingPaused" is gone + Send on-chain to the second wallet an amount above the savings balance and below the spending balance + Verify the send completes and the spending balance fell by the amount plus fee + + From cd733da80fafeb990449508e2151483067d033f9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 04:55:23 +0100 Subject: [PATCH 07/10] feat: one bitcoin balance and clear transfer errors with a primary node --- .../to/bitkit/repositories/PrimaryNodeRepo.kt | 18 +++++-- .../java/to/bitkit/repositories/WalletRepo.kt | 21 ++++++-- .../primary/PrimaryTransferViewModel.kt | 2 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 48 +++++++++++++++---- .../bitkit/ui/screens/wallets/HomeUiState.kt | 1 + .../ui/screens/wallets/HomeViewModel.kt | 8 ++++ app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 22 files changed, 97 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt index 7ca9e182c6..51386bbdcd 100644 --- a/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt @@ -135,11 +135,21 @@ class PrimaryNodeRepo @Inject constructor( lightningRepo.createPrimaryInvoice(amountSats, description, maxFeeMsat).mapPrimaryReceiveError() } - /** How much of the confirmed savings can move into the primary channel now, and at what fee. */ - suspend fun fundingPlan(): Result = withContext(bgDispatcher) { + /** + * How much of the confirmed savings can move into the primary channel now, and at what fee. + * For a manual transfer, an offline primary or a pending funding fails instead of planning nothing. + */ + suspend fun fundingPlan(isManual: Boolean = false): Result = withContext(bgDispatcher) { runSuspendCatching { - if (lightningRepo.primaryNode == null || !isPrimaryConnected()) return@runSuspendCatching null - if (lightningRepo.hasPendingFundingOperation().getOrThrow()) return@runSuspendCatching null + if (lightningRepo.primaryNode == null) return@runSuspendCatching null + if (!isPrimaryConnected()) { + if (isManual) throw PrimaryReceiveError.Offline(null) + return@runSuspendCatching null + } + if (lightningRepo.hasPendingFundingOperation().getOrThrow()) { + if (isManual) throw PrimaryWalletBusyError() + return@runSuspendCatching null + } val spendable = lightningRepo.getBalancesAsync().getOrThrow().spendableOnchainBalanceSats val inputs = lightningRepo.listSpendableOutputs().getOrThrow().size.toULong() val feeRate = lightningRepo.channelFundingFeeRate().getOrThrow() diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index ca7a26bc32..78dcd6a87b 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -88,6 +88,10 @@ class WalletRepo @Inject constructor( private var eventSyncJob: Job? = null private var primaryFundingJob: Job? = null + + /** An invoice the primary node delivers, approved by the user for its amount; receive refreshes keep it. */ + @Volatile + private var approvedPrimaryInvoice: Pair? = null private val receiveStateMutex = Mutex() private val receiveUpdateStatusLock = Any() private var pendingReceiveUpdates = 0 @@ -452,6 +456,9 @@ class WalletRepo @Inject constructor( settledReceiveInvoice: SettledReceiveInvoice? = null, settledReceiveAddress: SettledReceiveAddress? = null, ) { + if (settledReceiveInvoice != null && settledReceiveInvoice.bolt11 == approvedPrimaryInvoice?.first) { + approvedPrimaryInvoice = null + } val trackPending = synchronized(receiveUpdateStatusLock) { val cacheLoadPending = pendingReceiveCacheLoads > 0 if (!shouldHandleReceiveEvent(event, settledReceiveInvoice, settledReceiveAddress, cacheLoadPending)) return @@ -952,6 +959,7 @@ class WalletRepo @Inject constructor( _walletState.update { it.copy(bip21AmountSats = amountSats, bip21Description = description) } }, ) { + approvedPrimaryInvoice = bolt11 to amountSats runSuspendCatching { setBolt11Locked(bolt11, amountSats, description) } } @@ -971,10 +979,15 @@ class WalletRepo @Inject constructor( emptyList() } - val bolt11 = if (canCreateLightningInvoice(amountSats)) { - lightningRepo.createInvoice(amountSats, description).getOrThrow() - } else { - "" + val approved = approvedPrimaryInvoice?.takeIf { (invoice, sats) -> + sats == amountSats && runCatching { !Bolt11Invoice.fromStr(invoice).isExpired() }.getOrDefault(false) + } + val bolt11 = when { + approved != null -> approved.first + canCreateLightningInvoice( + amountSats + ) -> lightningRepo.createInvoice(amountSats, description).getOrThrow() + else -> "" } setBolt11Locked(bolt11, amountSats, description) val newBip21Url = _walletState.value.bip21 diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferViewModel.kt index d6d8cfbe4a..e5a0736ba6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferViewModel.kt @@ -47,7 +47,7 @@ class PrimaryTransferViewModel @Inject constructor( _uiState.update { PrimaryTransferUiState(hasPrimaryNode = false) } return@launch } - primaryNodeRepo.fundingPlan() + primaryNodeRepo.fundingPlan(isManual = true) .onSuccess { plan -> _uiState.update { PrimaryTransferUiState(plan = plan) } } .onFailure { error -> _uiState.update { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index d2c77ed576..ec5b04bf9b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -131,6 +131,7 @@ import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.effectiveSize +import to.bitkit.models.safe import to.bitkit.models.toBalance import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlockModel @@ -685,6 +686,7 @@ private fun WalletPage( VerticalSpacer(32.dp) BalancesSection( balances = balances, + isUnified = homeUiState.hasPrimaryNode, hardwareWallets = homeUiState.hardwareWallets, onNavigateToSavings = onNavigateToSavings, onNavigateToSpending = onNavigateToSpending, @@ -747,11 +749,49 @@ private fun WalletPage( @Composable private fun BalancesSection( balances: BalanceState, + isUnified: Boolean, hardwareWallets: ImmutableList, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, onClickUsdt: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + if (isUnified) { + Row(modifier = Modifier.fillMaxWidth()) { + WalletBalanceView( + title = stringResource(R.string.primary_node__balance), + sats = (balances.totalOnchainSats.safe() + balances.totalLightningSats.safe()).toLong(), + icon = painterResource(id = R.drawable.ic_btc_circle), + modifier = Modifier + .clickableAlpha(onClick = onNavigateToSpending) + .padding(vertical = 4.dp) + .testTag("ActivityBitcoin") + ) + } + } else { + SavingsAndSpending( + balances = balances, + onNavigateToSavings = onNavigateToSavings, + onNavigateToSpending = onNavigateToSpending, + ) + } + + AdditionalWallets( + wallets = hardwareWallets, + showUsdt = !LocalInspectionMode.current && + (appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true), + onClickHardwareWallet = onClickHardwareWallet, + onClickUsdt = onClickUsdt, + ) + } +} + +@Composable +private fun SavingsAndSpending( + balances: BalanceState, + onNavigateToSavings: () -> Unit, + onNavigateToSpending: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -780,14 +820,6 @@ private fun BalancesSection( .testTag("ActivitySpending") ) } - - AdditionalWallets( - wallets = hardwareWallets, - showUsdt = !LocalInspectionMode.current && - (appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true), - onClickHardwareWallet = onClickHardwareWallet, - onClickUsdt = onClickUsdt, - ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt index 0156048dbd..bdc4d18f73 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt @@ -37,6 +37,7 @@ data class HomeUiState( val isEditingWidgets: Boolean = false, val deleteWidgetAlert: WidgetType? = null, val showEmptyState: Boolean = false, + val hasPrimaryNode: Boolean = false, val currentPage: Int = 0, val showWidgetsOnboardingHint: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index afee832b40..2070130cf1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.update @@ -23,6 +24,7 @@ import to.bitkit.data.isUsdtEnabled import to.bitkit.env.Env import to.bitkit.models.ActivityBannerType import to.bitkit.models.BannerItem +import to.bitkit.models.PrimaryNode import to.bitkit.models.Suggestion import to.bitkit.models.WidgetType import to.bitkit.models.widget.ArticleModel @@ -73,6 +75,12 @@ class HomeViewModel @Inject constructor( } private fun setupStateObservation() { + viewModelScope.launch { + settingsStore.data + .map { PrimaryNode.effective(it.primaryNodeUri) != null } + .distinctUntilChanged() + .collect { has -> _uiState.update { it.copy(hasPrimaryNode = has) } } + } viewModelScope.launch { combine( settingsStore.data, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 55dd725e09..29329ffb4d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -383,6 +383,7 @@ عنوان URI للعقدة publickey@host:port لا يوجد عنوان معروف لهذا المفتاح العام. امسح عنوان URI الكامل publickey@host:port ضوئيًا أو الصقه. + بيتكوين ما زال هناك تغيير معلق في قناتك مع عقدتك الأساسية. حاول مرة أخرى بعد تأكيده. اتصال تم اتصال العقدة الأساسية diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 4ac60992ca..bdf8c42e14 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -390,6 +390,7 @@ URI del nodo publickey@host:port Esta clave pública no tiene una dirección conocida. Escanee o pegue el URI completo publickey@host:port. + Bitcoin Aún hay un cambio pendiente en su canal con su nodo principal. Inténtelo de nuevo cuando se confirme. Conectar Nodo principal conectado diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 14db5225d0..cf839c7a02 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -387,6 +387,7 @@ URI del node publickey@host:port Aquesta clau pública no té cap adreça coneguda. Escaneja o enganxa l\'URI complet publickey@host:port. + Bitcoin Encara hi ha un canvi pendent al teu canal amb el node principal. Torna-ho a provar quan es confirmi. Connecta Node principal connectat diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 91d52c12d6..2ad4356c97 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -390,6 +390,7 @@ URI uzlu publickey@host:port Tento veřejný klíč nemá známou adresu. Naskenujte nebo vložte celé URI publickey@host:port. + Bitcoin Změna vašeho kanálu s primárním uzlem stále probíhá. Zkuste to znovu, až bude potvrzena. Připojit Primární uzel připojen diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 66c19cf2a5..63e7946ec4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -372,6 +372,7 @@ Node-URI publickey@host:port Für diesen Public Key ist keine Adresse bekannt. Scanne oder füge den vollständigen URI publickey@host:port ein. + Bitcoin Eine Änderung an deinem Kanal mit deinem primären Node steht noch aus. Versuche es erneut, sobald sie bestätigt ist. Verbinden Primärer Node verbunden diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 31d5ff6a40..751cb0d35a 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -383,6 +383,7 @@ URI κόμβου publickey@host:port Αυτό το δημόσιο κλειδί δεν έχει γνωστή διεύθυνση. Σάρωσε ή επικόλλησε ολόκληρο το URI publickey@host:port. + Μπιτκόιν Μια αλλαγή στο κανάλι σου με τον κύριο κόμβο εκκρεμεί ακόμη. Δοκίμασε ξανά μόλις επιβεβαιωθεί. Σύνδεση Ο κύριος κόμβος συνδέθηκε diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 8f6bb3f6b6..c6fbe94537 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -389,6 +389,7 @@ URI del nodo publickey@host:port Esta clave pública no tiene una dirección conocida. Escanee o pegue el URI completo publickey@host:port. + Bitcoin Aún hay un cambio pendiente en su canal con su nodo principal. Inténtelo de nuevo cuando se confirme. Conectar Nodo principal conectado diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0e9eea907b..669bed55c2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -386,6 +386,7 @@ URI del nodo publickey@host:port Esta clave pública no tiene una dirección conocida. Escanee o pegue el URI completo publickey@host:port. + Bitcoin Aún hay un cambio pendiente en su canal con su nodo principal. Inténtelo de nuevo cuando se confirme. Conectar Nodo principal conectado diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 93696b7a18..d8ead885f9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -371,6 +371,7 @@ URI du nœud publickey@host:port Cette clé publique n\'a pas d\'adresse connue. Scannez ou collez l\'URI complet publickey@host:port. + Bitcoin Une modification de votre canal avec votre nœud principal est encore en attente. Réessayez une fois qu\'elle sera confirmée. Connecter Nœud principal connecté diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0eb4b51d98..3f7a48a0e4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -388,6 +388,7 @@ URI del nodo publickey@host:port Questa chiave pubblica non ha un indirizzo noto. Scansiona o incolla l\'URI completo publickey@host:port. + Bitcoin Una modifica al tuo canale con il nodo principale è ancora in sospeso. Riprova quando sarà confermata. Connetti Nodo principale connesso diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7ba3394eea..dfa91c217c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -387,6 +387,7 @@ Node-URI publickey@host:port Voor deze publieke sleutel is geen adres bekend. Scan of plak de volledige URI publickey@host:port. + Bitcoin Een wijziging aan je kanaal met je primaire node is nog in behandeling. Probeer het opnieuw zodra die bevestigd is. Verbinden Primaire node verbonden diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 7c212f1730..018806416c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -371,6 +371,7 @@ URI węzła publickey@host:port Ten klucz publiczny nie ma znanego adresu. Zeskanuj lub wklej pełny URI publickey@host:port. + Bitcoin Zmiana w Twoim kanale z węzłem głównym nadal oczekuje. Spróbuj ponownie po jej potwierdzeniu. Połącz Węzeł główny połączony diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e56599a78e..197a2e2787 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -391,6 +391,7 @@ URI do nó publickey@host:port Esta chave pública não tem um endereço conhecido. Leia ou cole o URI completo publickey@host:port. + Bitcoin Ainda há uma alteração pendente no canal com seu nó principal. Tente novamente quando for confirmada. Conectar Nó principal conectado diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e3155d3512..a590376465 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -354,6 +354,7 @@ URI do nó publickey@host:port Esta chave pública não tem um endereço conhecido. Leia ou cole o URI completo publickey@host:port. + Bitcoin Ainda há uma alteração pendente no seu canal com o seu nó principal. Tente novamente quando for confirmada. Conectar Nó principal conectado diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5dc85d2cd5..6fcae1658a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -401,6 +401,7 @@ URI ноды publickey@host:port Для этого публичного ключа адрес неизвестен. Отсканируйте или вставьте полный URI publickey@host:port. + Биткоин Изменение вашего канала с основной нодой всё ещё в обработке. Повторите попытку после подтверждения. Подключиться Основная нода подключена diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd5a2eb388..7b6a1c7d1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -577,6 +577,7 @@ Node URI publickey@host:port This public key has no known address. Scan or paste the full publickey@host:port URI. + Bitcoin A change to your channel with your primary node is still pending. Try again once it confirms. Connect Primary node connected From 3e8e8e618a5ab59c238979c67e97f78fd7306fc7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 07:35:15 +0100 Subject: [PATCH 08/10] fix: primary receives, transfers and errors from regtest qa --- .../bitkit/models/ReceiveLiquidityDecision.kt | 2 + .../to/bitkit/repositories/LightningRepo.kt | 7 ++ .../to/bitkit/repositories/PrimaryNodeRepo.kt | 66 +++++++++++-------- .../java/to/bitkit/repositories/WalletRepo.kt | 7 +- .../java/to/bitkit/services/CoreService.kt | 11 ++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 2 + .../screens/transfer/SavingsProgressScreen.kt | 15 ++++- .../screens/wallets/SpendingWalletScreen.kt | 5 +- .../wallets/receive/ReceiveQrScreen.kt | 2 + .../settings/primaryNode/PrimaryNodeScreen.kt | 3 +- .../primaryNode/PrimaryNodeViewModel.kt | 7 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 33 +++++++--- .../to/bitkit/viewmodels/SettingsViewModel.kt | 4 ++ .../to/bitkit/viewmodels/TransferViewModel.kt | 31 ++++----- .../models/ReceiveLiquidityDecisionTest.kt | 12 ++++ .../repositories/PrimaryNodeRepoTest.kt | 13 ++++ gradle/libs.versions.toml | 4 +- 17 files changed, 161 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt index 9b525c9731..0eec96eb8c 100644 --- a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt +++ b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt @@ -28,7 +28,9 @@ object ReceiveLiquidityDecision { hasReadyChannels: Boolean, inboundCapacitySats: ULong?, invoiceAmountSats: ULong?, + hasPrimaryInvoice: Boolean = false, ): Boolean { + if (hasPrimaryInvoice) return true if (!hasReadyChannels || inboundCapacitySats == null) return false if (invoiceAmountSats == null || invoiceAmountSats == 0uL) { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index fb458fd19e..2bf5d01bf6 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -823,6 +823,13 @@ class LightningRepo @Inject constructor( } is Event.ChannelClosed -> scope.launch { registerClosedChannel(event.channelId, event.reason) } + is Event.PaymentReceived, + is Event.PaymentSuccessful, + is Event.PaymentFailed, + is Event.SplicePending, + is Event.SpliceFailed, + -> scope.launch { refreshChannelsAndPeers() } + else -> Unit } } diff --git a/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt index 51386bbdcd..f163a08434 100644 --- a/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt @@ -26,8 +26,8 @@ import to.bitkit.models.PrimaryNodeError import to.bitkit.models.USat import to.bitkit.models.toPeerDetails import to.bitkit.utils.AppError -import to.bitkit.utils.LdkError import to.bitkit.utils.Logger +import to.bitkit.utils.asNodeException import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -75,14 +75,14 @@ class PrimaryNodeRepo @Inject constructor( /** The ready channel with the primary, if the wallet has one. */ fun primaryChannel(): ChannelDetails? { val primary = lightningRepo.primaryNode ?: return null - return lightningRepo.lightningState.value.channels.firstOrNull { + return lightningRepo.getChannels().orEmpty().firstOrNull { it.counterpartyNodeId == primary.nodeId && it.isChannelReady } } fun isPrimaryConnected(): Boolean { val primary = lightningRepo.primaryNode ?: return false - return lightningRepo.lightningState.value.peers.any { it.nodeId == primary.nodeId && it.isConnected } + return lightningRepo.getPeers().orEmpty().any { it.nodeId == primary.nodeId && it.isConnected } } /** @@ -96,7 +96,7 @@ class PrimaryNodeRepo @Inject constructor( val primary = if (parsed.address != null) parsed else parsed.copy(address = knownAddress(parsed.nodeId)) val current = lightningRepo.primaryNode val hasChannelWithCurrent = current != null && current.nodeId != primary.nodeId && - lightningRepo.lightningState.value.channels.any { it.counterpartyNodeId == current.nodeId } + lightningRepo.getChannels().orEmpty().any { it.counterpartyNodeId == current.nodeId } if (hasChannelWithCurrent) throw PrimaryNodeError.ConnectionInUse() val previous = settingsStore.data.first() @@ -160,17 +160,27 @@ class PrimaryNodeRepo @Inject constructor( /** Moves confirmed savings into the primary channel when the plan allows it and funding isn't paused. */ suspend fun fundIfNeeded(): Result = withContext(bgDispatcher) { if (settingsStore.data.first().primaryFundingPaused) return@withContext Result.success(Unit) - val plan = fundingPlan().getOrElse { return@withContext Result.failure(it) } - ?: return@withContext Result.success(Unit) - if (!plan.isAutomatic) return@withContext Result.success(Unit) - Logger.info("Funding primary channel with '${plan.amountSats}' sats, fee '${plan.feeSats}'", context = TAG) - executeFunding(plan) + if (!operationMutex.tryLock()) return@withContext Result.success(Unit) + try { + val plan = fundingPlan().getOrElse { return@withContext Result.failure(it) } + ?: return@withContext Result.success(Unit) + if (!plan.isAutomatic) return@withContext Result.success(Unit) + Logger.info("Funding primary channel with '${plan.amountSats}' sats, fee '${plan.feeSats}'", context = TAG) + executeFunding(plan) + } finally { + operationMutex.unlock() + } } /** Transfer to Spending: moves the planned savings now, below the automatic fee limit too, and resumes funding. */ suspend fun transferToSpending(plan: PrimaryFundingPlan): Result = withContext(bgDispatcher) { - settingsStore.update { it.copy(primaryFundingPaused = false) } - executeFunding(plan) + operationMutex.withLock { + if (lightningRepo.hasPendingFundingOperation().getOrDefault(false)) { + return@withContext Result.failure(PrimaryWalletBusyError()) + } + settingsStore.update { it.copy(primaryFundingPaused = false) } + executeFunding(plan) + } } /** Fee for paying [amountSats] on-chain out of the primary channel, or null when the channel can't cover it. */ @@ -229,28 +239,29 @@ class PrimaryNodeRepo @Inject constructor( } } - private suspend fun executeFunding(plan: PrimaryFundingPlan): Result = operationMutex.withLock { - runSuspendCatching { - val primary = lightningRepo.primaryNode ?: throw PrimaryNodeError.NotConfigured() - val channel = primaryChannel() - if (channel != null) { - lightningRepo.spliceIn(channel, plan.amountSats).getOrThrow() - } else { - val peer = primary.toPeerDetails() ?: throw PrimaryNodeError.AddressUnknown() - lightningRepo.openChannel(peer, plan.amountSats).getOrThrow() - } - Unit + private suspend fun executeFunding(plan: PrimaryFundingPlan): Result = runSuspendCatching { + val primary = lightningRepo.primaryNode ?: throw PrimaryNodeError.NotConfigured() + val channel = primaryChannel() + if (channel != null) { + lightningRepo.spliceIn(channel, plan.amountSats).getOrThrow() + } else { + val peer = primary.toPeerDetails() ?: throw PrimaryNodeError.AddressUnknown() + lightningRepo.openChannel(peer, plan.amountSats).getOrThrow() } + Unit } private suspend fun verifyPrimary(primary: PrimaryNode): Result = runSuspendCatching { lightningRepo.restartNode().getOrThrow() val peer = primary.toPeerDetails() ?: throw PrimaryNodeError.AddressUnknown() + lightningRepo.getPeers().orEmpty() + .firstOrNull { it.nodeId == peer.nodeId && it.isConnected && it.address != peer.address } + ?.let { lightningRepo.disconnectPeer(it) } lightningRepo.connectPeer(peer).mapPrimaryReceiveError().getOrThrow() lightningRepo.checkPrimaryConnection(primary.nodeId).mapPrimaryReceiveError().getOrThrow() } - private fun knownAddress(nodeId: String): String = lightningRepo.lightningState.value.peers + private fun knownAddress(nodeId: String): String = lightningRepo.getPeers().orEmpty() .firstOrNull { it.nodeId == nodeId }?.address ?: throw PrimaryNodeError.AddressUnknown() } @@ -265,6 +276,8 @@ data class PrimaryFundingPlan( /** * Plans moving [spendableSats] of savings, held in [inputs] outputs, into the primary channel at [feeRate]. + * ldk-node leaves outputs of a channel's own funding transaction out of [inputs], such as savings from a + * splice-out, so the estimate counts at least one input whenever there is something to move. * Returns null when there is nothing worth moving; [PrimaryFundingPlan.isAutomatic] is false when the * fee is above [PrimaryNodeRepo.MAX_FUNDING_FEE_PERCENT], so only a manual transfer moves it. */ @@ -274,8 +287,9 @@ internal fun primaryFundingPlan( feeRate: ULong, hasChannel: Boolean, ): PrimaryFundingPlan? { - if (inputs == 0uL || feeRate == 0uL) return null - val fee = feeRate * (PrimaryNodeRepo.FUNDING_BASE_VBYTES + PrimaryNodeRepo.FUNDING_INPUT_VBYTES * inputs) + if (spendableSats == 0uL || feeRate == 0uL) return null + val inputCount = inputs.coerceAtLeast(1uL) + val fee = feeRate * (PrimaryNodeRepo.FUNDING_BASE_VBYTES + PrimaryNodeRepo.FUNDING_INPUT_VBYTES * inputCount) if (USat(spendableSats) <= USat(fee)) return null val amount = USat(spendableSats) - USat(fee) return PrimaryFundingPlan( @@ -302,7 +316,7 @@ sealed class PrimaryReceiveError(message: String, cause: Throwable? = null) : Ap internal fun Result.mapPrimaryReceiveError(): Result = recoverCatching { throw it.toPrimaryReceiveError() } -internal fun Throwable.toPrimaryReceiveError(): Throwable = when ((this as? LdkError)?.nodeException) { +internal fun Throwable.toPrimaryReceiveError(): Throwable = when (asNodeException()) { is NodeException.LiquiditySourceInsufficientFunds -> PrimaryReceiveError.InsufficientFunds() is NodeException.LiquidityRequestRejected -> PrimaryReceiveError.Rejected() is NodeException.LiquidityRequestTimedOut -> PrimaryReceiveError.TimedOut() diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 78dcd6a87b..e729fd0cee 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -815,7 +815,10 @@ class WalletRepo @Inject constructor( } cacheStore.update { it.copy(bolt11 = bolt11, bolt11PaymentHash = paymentHash, bip21 = bip21) } committedReceiveBip21 = bip21 - _walletState.update { it.copy(bolt11 = bolt11, bip21 = bip21, hasReceiveInvoiceError = false) } + val isPrimaryInvoice = bolt11.isNotEmpty() && bolt11 == approvedPrimaryInvoice?.first + _walletState.update { + it.copy(bolt11 = bolt11, bip21 = bip21, hasReceiveInvoiceError = false, isPrimaryInvoice = isPrimaryInvoice) + } } suspend fun setBip21(bip21: String) = updateReceiveState(trackPending = false) { setBip21Locked(bip21) } @@ -1053,6 +1056,8 @@ data class WalletState( val isReceiveInvoiceUpdating: Boolean = false, val bip21AmountSats: ULong? = null, val bip21Description: String = "", + /** The Lightning invoice is one the user approved with the primary node's fee, not one inbound capacity covers. */ + val isPrimaryInvoice: Boolean = false, val selectedTags: ImmutableList = persistentListOf(), val walletExists: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 7320247efd..d29f30ae1a 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -834,6 +834,17 @@ class ActivityService( processBolt11(kind = kind, payment = payment, state = state) } + is PaymentKind.Bolt11Jit -> { + val bolt11 = PaymentKind.Bolt11( + hash = kind.hash, + preimage = kind.preimage, + secret = kind.secret, + description = kind.description, + bolt11 = kind.bolt11, + ) + processBolt11(kind = bolt11, payment = payment, state = state) + } + else -> Unit // Handle spontaneous payments if needed } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index a5caff42d3..d988004c81 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1203,6 +1203,7 @@ private fun NavGraphBuilder.home( } deepLinkableComposable { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() + val hasPrimaryNode by settingsViewModel.hasPrimaryNode.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() @@ -1221,6 +1222,7 @@ private fun NavGraphBuilder.home( }, onTransferFromSavingsClick = { navController.navigateTo(Routes.PrimaryTransfer) }, onBackClick = { navController.popBackStack() }, + hasPrimaryNode = hasPrimaryNode, ) } deepLinkableComposable { diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt index cdf45cacdc..d7d14e5c2a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt @@ -38,6 +38,7 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.transfer.components.TransferAnimationView import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.primaryErrorMessageRes import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent import to.bitkit.ui.utils.withAccentBoldBright @@ -76,6 +77,14 @@ fun SavingsProgressScreen( onSuccess = { progressState = SavingsProgressState.SUCCESS }, onInterrupted = { progressState = SavingsProgressState.INTERRUPTED }, onGiveUp = { app.showSheet(Sheet.ForceTransfer) }, + onFailure = { + app.toast( + type = Toast.ToastType.ERROR, + title = resources.getString(R.string.common__error), + description = resources.getString(it.primaryErrorMessageRes()), + ) + onTransferUnavailable() + }, ) } } @@ -122,8 +131,12 @@ private suspend fun runChannelClose( onSuccess: () -> Unit, onInterrupted: () -> Unit, onGiveUp: () -> Unit, + onFailure: (Throwable) -> Unit, ) { - val channelsFailedToCoopClose = transfer.closeSelectedChannels() + val channelsFailedToCoopClose = transfer.closeSelectedChannels().getOrElse { + onFailure(it) + return + } if (channelsFailedToCoopClose.isEmpty()) { wallet.refreshState() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 2f2a40b2ad..a17960d7bc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -68,6 +68,7 @@ fun SpendingWalletScreen( onTransferToSavingsClick: () -> Unit, onTransferFromSavingsClick: () -> Unit, onBackClick: () -> Unit, + hasPrimaryNode: Boolean = false, balances: BalanceState = LocalBalances.current, ) { val showEmptyState by remember(balances.totalLightningSats, lightningActivities.size) { @@ -80,8 +81,8 @@ fun SpendingWalletScreen( val hasChannels = channels.isNotEmpty() mutableStateOf(hasLnBalance && hasChannels) } - val canTransferFromSavings by remember(showEmptyState, balances.totalOnchainSats) { - mutableStateOf(showEmptyState && balances.totalOnchainSats > 0uL) + val canTransferFromSavings by remember(showEmptyState, hasPrimaryNode, balances.totalOnchainSats) { + mutableStateOf((showEmptyState || hasPrimaryNode) && balances.totalOnchainSats > 0uL) } val hazeState = rememberHazeState() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 2b96dce0ba..e113a4e02d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -134,11 +134,13 @@ fun ReceiveQrScreen( hasReadyChannels, readyInboundLiquiditySats, walletState.bip21AmountSats, + walletState.isPrimaryInvoice, ) { ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels = hasReadyChannels, inboundCapacitySats = readyInboundLiquiditySats, invoiceAmountSats = walletState.bip21AmountSats, + hasPrimaryInvoice = walletState.isPrimaryInvoice, ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt index be7549bc69..cf895b245e 100644 --- a/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt @@ -163,7 +163,7 @@ private fun Content( SecondaryButton( text = stringResource(R.string.settings__es__button_reset), onClick = onClickReset, - enabled = !uiState.isLoading && !uiState.isDefault, + enabled = !uiState.isLoading && !uiState.isInputDefault, modifier = Modifier .weight(1f) .testTag("PrimaryNodeReset") @@ -192,7 +192,6 @@ private fun Preview() { currentUri = "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c@bore.pub:39101", input = "024a6cc632544189896cca8e06e375bdb1d1f34a71a4c25d09a39cf0ed920a299c@bore.pub:39101", isConnected = true, - isDefault = true, ), ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeViewModel.kt index 31e2e06f6e..a4e5cfdefd 100644 --- a/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeViewModel.kt @@ -48,7 +48,6 @@ class PrimaryNodeViewModel @Inject constructor( it.copy( currentUri = primary?.uri.orEmpty(), input = if (it.hasEdited) it.input else primary?.uri.orEmpty(), - isDefault = primary?.uri == PrimaryNode.parseOrNull(Env.defaultPrimaryNodeUri)?.uri, isConnected = isConnected, isFundingPaused = isPaused, ) @@ -99,9 +98,11 @@ data class PrimaryNodeUiState( val currentUri: String = "", val input: String = "", val hasEdited: Boolean = false, - val isDefault: Boolean = false, + val defaultUri: String = Env.defaultPrimaryNodeUri, val isConnected: Boolean = false, val isFundingPaused: Boolean = false, val isLoading: Boolean = false, val error: String? = null, -) +) { + val isInputDefault: Boolean get() = input == defaultUri +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ef95baec32..ec9b4bc68f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2358,8 +2358,8 @@ class AppViewModel @Inject constructor( is Event.PaymentSuccessful -> handlePaymentSuccessful(event) is Event.ProbeFailed -> Unit is Event.ProbeSuccessful -> Unit - is Event.SpliceFailed -> Unit - is Event.SplicePending -> Unit + is Event.SpliceFailed -> walletRepo.syncBalances() + is Event.SplicePending -> walletRepo.syncNodeAndWallet() is Event.SyncCompleted -> handleSyncCompleted(event) is Event.SyncProgress -> Unit } @@ -6894,15 +6894,25 @@ class AppViewModel @Inject constructor( ) } - /** True when savings can't cover [amount] and its fee, but the primary channel can. */ private suspend fun shouldSendFromPrimaryChannel(address: String, amount: ULong): Boolean { val state = _sendUiState.value - if (state.selectedUtxos != null || primaryNodeRepo.primaryChannel() == null) return false + return primaryChannelSendFee(address, amount, state.speed, state.selectedUtxos, state.feeRates) != null + } + + /** The splice-out fee when savings can't cover [amount] and its fee, but the primary channel can; else null. */ + private suspend fun primaryChannelSendFee( + address: String, + amount: ULong, + speed: TransactionSpeed, + selectedUtxos: List?, + feeRates: FeeRates?, + ): ULong? { + if (selectedUtxos != null || primaryNodeRepo.primaryChannel() == null) return null val spendable = lightningRepo.lightningState.value.balances?.spendableOnchainBalanceSats ?: 0uL - val fee = lightningRepo.calculateTotalFee(amount, address, state.speed, feeRates = state.feeRates).getOrNull() - if (fee != null && USat(amount) + USat(fee) <= spendable) return false - val feeRate = lightningRepo.getFeeRateForSpeed(state.speed, state.feeRates).getOrNull() ?: return false - return primaryNodeRepo.spliceOutFee(amount, feeRate) != null + val fee = lightningRepo.calculateTotalFee(amount, address, speed, feeRates = feeRates).getOrNull() + if (fee != null && USat(amount) + USat(fee) <= spendable) return null + val feeRate = lightningRepo.getFeeRateForSpeed(speed, feeRates).getOrNull() ?: return null + return primaryNodeRepo.spliceOutFee(amount, feeRate) } private suspend fun sendOnchainFromSavings( @@ -7184,6 +7194,13 @@ class AppViewModel @Inject constructor( satsPerVByte = satsPerVByte.toULong(), ).also { requireCurrentPaymentPreparation(walletGeneration) }.getOrNull()?.toLong() ?: 0 } + primaryChannelSendFee( + address = currentState.address, + amount = currentState.amount, + speed = speed ?: currentState.speed, + selectedUtxos = currentState.selectedUtxos, + feeRates = currentState.feeRates, + )?.let { return it.toLong() } return lightningRepo.calculateTotalFee( amountSats = currentState.amount, address = currentState.address, diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 4dc902cbac..7e4803f0df 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -24,6 +24,7 @@ import to.bitkit.data.WidgetsStore import to.bitkit.data.hasPaykitState import to.bitkit.ext.runSuspendCatching import to.bitkit.flags.PaykitFeatureFlags +import to.bitkit.models.PrimaryNode import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ContactPaymentSettingsRepo @@ -136,6 +137,9 @@ class SettingsViewModel @Inject constructor( val hasSeenSavingsIntro = settingsStore.data.map { it.hasSeenSavingsIntro } .asStateFlow(initialValue = false) + val hasPrimaryNode = settingsStore.data.map { PrimaryNode.effective(it.primaryNodeUri) != null } + .asStateFlow(initialValue = false) + fun setHasSeenSavingsIntro(value: Boolean) { viewModelScope.launch { settingsStore.update { it.copy(hasSeenSavingsIntro = value) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 0cb6a8f4c7..fb4f940d59 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -34,7 +34,6 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toUserMessage import to.bitkit.models.Toast -import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PrimaryNodeRepo @@ -308,17 +307,25 @@ class TransferViewModel @Inject constructor( else -> SavingsTransferMode.CLOSE } - /** Closes the channels selected earlier, pending closure */ - suspend fun closeSelectedChannels() = closeChannels(channelsToClose) + /** + * Closes the channels selected earlier, pending closure. The primary channel stays open and its balance is + * spliced out to savings first; a failed splice-out fails the transfer instead of retrying as a close. + */ + suspend fun closeSelectedChannels(): Result> = runSuspendCatching { + val primaryChannelId = primaryNodeRepo.primaryChannel()?.channelId + if (primaryChannelId != null && channelsToClose.any { it.channelId == primaryChannelId }) { + val feeRate = lightningRepo.channelFundingFeeRate().getOrThrow() + primaryNodeRepo.transferToSavings(feeRate).getOrThrow() + channelsToClose = channelsToClose.filterNot { it.channelId == primaryChannelId } + } + closeChannels(channelsToClose) + } private suspend fun closeChannels(channels: List): List { lightningRepo.awaitPeerConnected() val channelsFailedToClose = coroutineScope { channels.map { channel -> async { - if (channel.channelId == primaryNodeRepo.primaryChannel()?.channelId) { - return@async spliceOutPrimaryChannel(channel) - } lightningRepo.closeChannel(channel) .onSuccess { transferRepo.createTransfer( @@ -339,18 +346,6 @@ class TransferViewModel @Inject constructor( return channelsFailedToClose } - /** The primary channel stays open: its balance is spliced out to savings instead. */ - private suspend fun spliceOutPrimaryChannel(channel: ChannelDetails): ChannelDetails? { - val feeRate = lightningRepo.getFeeRateForSpeed(TransactionSpeed.default()).getOrElse { return channel } - return primaryNodeRepo.transferToSavings(feeRate).fold( - onSuccess = { null }, - onFailure = { - Logger.warn("Failed to splice out primary channel '${channel.channelId}'", it, context = TAG) - channel - }, - ) - } - private var coopCloseRetryJob: Job? = null /** Retry to coop close the channel(s) for 30 min */ diff --git a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt index 7ea28de846..523d77f9a1 100644 --- a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt +++ b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt @@ -24,6 +24,18 @@ class ReceiveLiquidityDecisionTest { ) } + @Test + fun `approved primary invoice is shown without inbound liquidity`() { + assertTrue( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = false, + inboundCapacitySats = null, + invoiceAmountSats = 20_000u, + hasPrimaryInvoice = true, + ) + ) + } + @Test fun `variable lightning invoice requires non-zero inbound liquidity`() { assertFalse( diff --git a/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt index 429463b429..eb1149d98f 100644 --- a/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import org.junit.Test import org.lightningdevkit.ldknode.NodeException +import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -44,6 +45,16 @@ class PrimaryNodeRepoTest { assertNull(primaryFundingPlan(spendableSats = 500uL, inputs = 1uL, feeRate = 1uL, hasChannel = true)) } + @Test + fun `funding plan counts one input for savings ldk-node does not list as spendable`() { + val plan = requireNotNull( + primaryFundingPlan(spendableSats = 50_000uL, inputs = 0uL, feeRate = 2uL, hasChannel = true) + ) + + assertEquals(1_200uL, plan.feeSats) + assertEquals(48_800uL, plan.amountSats) + } + @Test fun `liquidity errors map to what the user is told`() { fun map(e: NodeException) = LdkError(e).toPrimaryReceiveError() @@ -57,7 +68,9 @@ class PrimaryNodeRepoTest { @Test fun `an unreachable primary is reported as offline`() { val error = LdkError(NodeException.ConnectionFailed("")).toPrimaryReceiveError() + val wrapped = AppError(LdkError(NodeException.ConnectionFailed(""))).toPrimaryReceiveError() assertIs(error) + assertIs(wrapped) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d920b54a9..2348a0e9eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,8 +67,8 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -# Lightning-first build of coreyphillips/ldk-node feat/lfbw-wallet-client (437ee299), hosted as a release asset -ldk-node-android = { module = "lfbw:ldk-node-android", version = "0.7.0-rc.78-lfbw.1" } +# Lightning-first build of coreyphillips/ldk-node feat/lfbw-wallet-client (437ee299) plus a splice-weight patch, hosted as a release asset +ldk-node-android = { module = "lfbw:ldk-node-android", version = "0.7.0-rc.78-lfbw.2" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } From 75e2ef3280bc265dcad88ace35214f3512d50a9e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 10:18:39 +0100 Subject: [PATCH 09/10] fix: primary channel sends and funding --- .../to/bitkit/repositories/LightningRepo.kt | 1 + .../to/bitkit/repositories/PrimaryNodeRepo.kt | 28 +++++++++++---- .../java/to/bitkit/viewmodels/AppViewModel.kt | 35 ++++++++++++++++++- .../repositories/PrimaryNodeRepoTest.kt | 12 +++---- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2bf5d01bf6..bdbd5cd1ce 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -824,6 +824,7 @@ class LightningRepo @Inject constructor( is Event.ChannelClosed -> scope.launch { registerClosedChannel(event.channelId, event.reason) } is Event.PaymentReceived, + is Event.BalanceChanged, is Event.PaymentSuccessful, is Event.PaymentFailed, is Event.SplicePending, diff --git a/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt index f163a08434..a72686c5b9 100644 --- a/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt @@ -12,8 +12,11 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeException +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PrimaryReceiveQuote import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher @@ -51,11 +54,14 @@ class PrimaryNodeRepo @Inject constructor( /** Automatic funding waits while its mining fee is above this share of the amount moved. */ const val MAX_FUNDING_FEE_PERCENT = 5uL - /** Virtual size of a splice or channel funding before its inputs, for fee estimates. */ - const val FUNDING_BASE_VBYTES = 300uL + /** + * Virtual size of a splice before its wallet inputs, for fee estimates: the channel's shared input and + * output plus one more output, with a margin over the 180 vB a splice-out to one address takes. + */ + const val FUNDING_BASE_VBYTES = 200uL - /** Virtual size each wallet input adds to a funding transaction. */ - const val FUNDING_INPUT_VBYTES = 300uL + /** Virtual size each wallet input adds to a funding transaction, with a margin, change included. */ + const val FUNDING_INPUT_VBYTES = 70uL /** How long a splice may take to reach its pending funding transaction. */ private val SPLICE_TIMEOUT = 60.seconds @@ -162,6 +168,7 @@ class PrimaryNodeRepo @Inject constructor( if (settingsStore.data.first().primaryFundingPaused) return@withContext Result.success(Unit) if (!operationMutex.tryLock()) return@withContext Result.success(Unit) try { + if (hasUnconfirmedDeposit()) return@withContext Result.success(Unit) val plan = fundingPlan().getOrElse { return@withContext Result.failure(it) } ?: return@withContext Result.success(Unit) if (!plan.isAutomatic) return@withContext Result.success(Unit) @@ -172,6 +179,12 @@ class PrimaryNodeRepo @Inject constructor( } } + private suspend fun hasUnconfirmedDeposit(): Boolean = lightningRepo.getPayments().getOrDefault(emptyList()).any { + val kind = it.kind + it.direction == PaymentDirection.INBOUND && kind is PaymentKind.Onchain && + kind.status is ConfirmationStatus.Unconfirmed + } + /** Transfer to Spending: moves the planned savings now, below the automatic fee limit too, and resumes funding. */ suspend fun transferToSpending(plan: PrimaryFundingPlan): Result = withContext(bgDispatcher) { operationMutex.withLock { @@ -186,16 +199,19 @@ class PrimaryNodeRepo @Inject constructor( /** Fee for paying [amountSats] on-chain out of the primary channel, or null when the channel can't cover it. */ fun spliceOutFee(amountSats: ULong, feeRate: ULong): ULong? { val channel = primaryChannel()?.takeIf { it.isUsable } ?: return null - val fee = feeRate * FUNDING_BASE_VBYTES + val fee = spliceOutFeeAt(feeRate) return fee.takeIf { USat(amountSats) + USat(it) <= channel.outboundCapacityMsat / 1000uL } } /** The most a single on-chain payment can take out of the primary channel at [feeRate]. */ fun maxSpliceOutSats(feeRate: ULong): ULong { val channel = primaryChannel()?.takeIf { it.isUsable } ?: return 0uL - return USat(channel.outboundCapacityMsat / 1000uL) - USat(feeRate * FUNDING_BASE_VBYTES) + return USat(channel.outboundCapacityMsat / 1000uL) - USat(spliceOutFeeAt(feeRate)) } + /** Estimated fee of a splice-out paying one address at [feeRate] sat/vB. */ + fun spliceOutFeeAt(feeRate: ULong): ULong = feeRate * FUNDING_BASE_VBYTES + /** Pays [amountSats] to [address] from the primary channel by splicing it out; returns the splice txid. */ suspend fun sendOnchain(address: String, amountSats: ULong, feeRate: ULong): Result = withContext(bgDispatcher) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ec9b4bc68f..81024f1537 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -27,6 +27,7 @@ import com.synonym.bitkitcore.LnurlChannelData import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.LnurlWithdrawData import com.synonym.bitkitcore.OnChainInvoice +import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.SortDirection @@ -108,6 +109,7 @@ import to.bitkit.ext.amountSats import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.channelId import to.bitkit.ext.claimableAtHeight +import to.bitkit.ext.create import to.bitkit.ext.getClipboardText import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.isFixedAmount @@ -117,6 +119,7 @@ import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat import to.bitkit.ext.nowMillis +import to.bitkit.ext.nowTimestamp import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.runSuspendCatching @@ -235,6 +238,7 @@ import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PubkyService +import to.bitkit.services.withConfirmation import to.bitkit.ui.ID_NOTIFICATION_SKIPPED import to.bitkit.ui.Routes import to.bitkit.ui.components.AllowanceRoute @@ -381,7 +385,7 @@ class AppViewModel @Inject constructor( requireCurrentPaymentPreparation(walletGeneration) lightningRepo.waitForUsableChannels() requireCurrentPaymentPreparation(walletGeneration) - walletRepo.refreshMaxSendLightning() + lightningRepo.refreshChannelsAndPeers(onRefreshed = walletRepo::refreshMaxSendLightning) requireCurrentPaymentPreparation(walletGeneration) } @@ -2376,6 +2380,7 @@ class AppViewModel @Inject constructor( } private suspend fun handleChannelReady(event: Event.ChannelReady) { + event.fundingTxo?.txid?.let { confirmPrimaryChannelSend(it) } refreshPaykitEndpointsAfterChannelAvailabilityChanged("channel ready") notifyChannelReady(event) delay(PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS) @@ -6877,6 +6882,7 @@ class AppViewModel @Inject constructor( return Result.failure(it) } return primaryNodeRepo.sendOnchain(address, amount, feeRate).map { txid -> + addPrimaryChannelSendActivity(txid, address, amount, primaryNodeRepo.spliceOutFeeAt(feeRate)) onBroadcast(txid) OnchainSendOutcome.Accepted(txid, amount) } @@ -6894,6 +6900,33 @@ class AppViewModel @Inject constructor( ) } + private suspend fun addPrimaryChannelSendActivity(txid: String, address: String, amount: ULong, fee: ULong) { + val now = nowTimestamp().epochSecond.toULong() + activityRepo.upsertActivity( + Activity.Onchain( + OnchainActivity.create( + id = txid, + txType = PaymentType.SENT, + txId = txid, + value = amount, + fee = fee, + address = address, + timestamp = now, + ) + ) + ) + } + + private suspend fun confirmPrimaryChannelSend(fundingTxid: String) { + val activity = activityRepo.getOnchainActivityByTxId(fundingTxid)?.takeIf { + !it.confirmed && it.txType == PaymentType.SENT + } ?: return + val now = nowTimestamp().epochSecond.toULong() + activityRepo.upsertActivity( + Activity.Onchain(activity.withConfirmation(isConfirmed = true, confirmTimestamp = now)) + ) + } + private suspend fun shouldSendFromPrimaryChannel(address: String, amount: ULong): Boolean { val state = _sendUiState.value return primaryChannelSendFee(address, amount, state.speed, state.selectedUtxos, state.feeRates) != null diff --git a/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt index eb1149d98f..8f63181c58 100644 --- a/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt @@ -17,8 +17,8 @@ class PrimaryNodeRepoTest { val plan = primaryFundingPlan(spendableSats = 50_000uL, inputs = 1uL, feeRate = 2uL, hasChannel = true) requireNotNull(plan) - assertEquals(1_200uL, plan.feeSats) - assertEquals(48_800uL, plan.amountSats) + assertEquals(540uL, plan.feeSats) + assertEquals(49_460uL, plan.amountSats) assertFalse(plan.opensChannel) assertTrue(plan.isAutomatic) } @@ -33,7 +33,7 @@ class PrimaryNodeRepoTest { @Test fun `automatic funding waits below the minimum or above the fee limit`() { val small = primaryFundingPlan(spendableSats = 24_999uL, inputs = 1uL, feeRate = 1uL, hasChannel = true) - val expensive = primaryFundingPlan(spendableSats = 30_000uL, inputs = 1uL, feeRate = 3uL, hasChannel = true) + val expensive = primaryFundingPlan(spendableSats = 30_000uL, inputs = 1uL, feeRate = 6uL, hasChannel = true) assertFalse(requireNotNull(small).isAutomatic) assertFalse(requireNotNull(expensive).isAutomatic) @@ -42,7 +42,7 @@ class PrimaryNodeRepoTest { @Test fun `funding plan is empty when nothing can be moved`() { assertNull(primaryFundingPlan(spendableSats = 0uL, inputs = 0uL, feeRate = 1uL, hasChannel = true)) - assertNull(primaryFundingPlan(spendableSats = 500uL, inputs = 1uL, feeRate = 1uL, hasChannel = true)) + assertNull(primaryFundingPlan(spendableSats = 200uL, inputs = 1uL, feeRate = 1uL, hasChannel = true)) } @Test @@ -51,8 +51,8 @@ class PrimaryNodeRepoTest { primaryFundingPlan(spendableSats = 50_000uL, inputs = 0uL, feeRate = 2uL, hasChannel = true) ) - assertEquals(1_200uL, plan.feeSats) - assertEquals(48_800uL, plan.amountSats) + assertEquals(540uL, plan.feeSats) + assertEquals(49_460uL, plan.amountSats) } @Test From f53033a84a10759b83965d112d9724279c345ebd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 29 Oct 2026 11:53:28 +0100 Subject: [PATCH 10/10] fix: confirm channel sends after a block --- .../java/to/bitkit/repositories/WalletRepo.kt | 20 +++++++++++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 ----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index e729fd0cee..bf01482376 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -1,9 +1,11 @@ package to.bitkit.repositories import androidx.compose.runtime.Immutable +import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview +import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.ImmutableList @@ -48,6 +50,7 @@ import to.bitkit.models.toBalance import to.bitkit.models.toDerivationPath import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService +import to.bitkit.services.withConfirmation import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase @@ -432,10 +435,27 @@ class WalletRepo @Inject constructor( syncBalances() transferRepo.syncTransferStates() activityRepo.syncActivities() + confirmPrimaryChannelSend() schedulePrimaryFunding() } } + /** + * A send out of the primary channel is a splice, so it confirms with the channel's funding transaction. + * The splice can lock in at 0 confirmations, so the channel's confirmation count decides, not `ChannelReady`. + */ + private suspend fun confirmPrimaryChannelSend() { + val channel = primaryNodeRepo.primaryChannel()?.takeIf { (it.confirmations ?: 0u) > 0u } ?: return + val fundingTxid = channel.fundingTxo?.txid ?: return + val activity = activityRepo.getOnchainActivityByTxId(fundingTxid)?.takeIf { + !it.confirmed && it.txType == PaymentType.SENT + } ?: return + val now = nowTimestamp().epochSecond.toULong() + activityRepo.upsertActivity( + Activity.Onchain(activity.withConfirmation(isConfirmed = true, confirmTimestamp = now)) + ) + } + private fun schedulePrimaryFunding() { if (primaryFundingJob?.isActive == true) return primaryFundingJob = repoScope.launch { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 81024f1537..3909cb5e77 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -238,7 +238,6 @@ import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.services.PaykitSdkOperationLock.Priority import to.bitkit.services.PubkyService -import to.bitkit.services.withConfirmation import to.bitkit.ui.ID_NOTIFICATION_SKIPPED import to.bitkit.ui.Routes import to.bitkit.ui.components.AllowanceRoute @@ -2380,7 +2379,6 @@ class AppViewModel @Inject constructor( } private suspend fun handleChannelReady(event: Event.ChannelReady) { - event.fundingTxo?.txid?.let { confirmPrimaryChannelSend(it) } refreshPaykitEndpointsAfterChannelAvailabilityChanged("channel ready") notifyChannelReady(event) delay(PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS) @@ -6917,16 +6915,6 @@ class AppViewModel @Inject constructor( ) } - private suspend fun confirmPrimaryChannelSend(fundingTxid: String) { - val activity = activityRepo.getOnchainActivityByTxId(fundingTxid)?.takeIf { - !it.confirmed && it.txType == PaymentType.SENT - } ?: return - val now = nowTimestamp().epochSecond.toULong() - activityRepo.upsertActivity( - Activity.Onchain(activity.withConfirmation(isConfirmed = true, confirmTimestamp = now)) - ) - } - private suspend fun shouldSendFromPrimaryChannel(address: String, amount: ULong): Boolean { val state = _sendUiState.value return primaryChannelSendFee(address, amount, state.speed, state.selectedUtxos, state.feeRates) != null