diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6198983ef9..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()) @@ -556,7 +557,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 +570,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/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 83a1ed564f..f59c21cbd1 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -39,6 +39,16 @@ 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 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 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 const val walletSyncTimeoutSecs = 10_uL @@ -164,6 +174,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/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 cf30c1d05b..0eec96eb8c 100644 --- a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt +++ b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt @@ -11,12 +11,16 @@ 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( val source: ReceiveLiquiditySource, val invoiceAmountSats: ULong, val inboundCapacitySats: ULong?, + val hasPrimaryNode: Boolean = false, ) object ReceiveLiquidityDecision { @@ -24,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) { @@ -36,9 +42,11 @@ object ReceiveLiquidityDecision { fun additionalLiquidityAction(params: ReceiveAdditionalLiquidityParams): ReceiveAdditionalLiquidityAction { return when { - params.source != ReceiveLiquiditySource.SPENDING -> 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 4f25822652..bdbd5cd1ce 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 @@ -821,6 +823,14 @@ 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, + is Event.SpliceFailed, + -> scope.launch { refreshChannelsAndPeers() } + else -> Unit } } @@ -1300,6 +1310,37 @@ 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") { + 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( amountMsats: ULong, description: String, @@ -2090,6 +2131,27 @@ class LightningRepo @Inject constructor( } } + suspend fun spliceIn( + channel: ChannelDetails, + amountSats: ULong, + ): Result = executeWhenNodeRunning("spliceIn") { + lightningService.spliceIn(channel, amountSats).also { + syncState() + } + } + + 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..a72686c5b9 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrimaryNodeRepo.kt @@ -0,0 +1,345 @@ +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 +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.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 +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 +import to.bitkit.models.toPeerDetails +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import to.bitkit.utils.asNodeException +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, + * 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 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, 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 + } + + 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.getChannels().orEmpty().firstOrNull { + it.counterpartyNodeId == primary.nodeId && it.isChannelReady + } + } + + fun isPrimaryConnected(): Boolean { + val primary = lightningRepo.primaryNode ?: return false + return lightningRepo.getPeers().orEmpty().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.getChannels().orEmpty().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. + * 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) 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() + 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) + 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) + Logger.info("Funding primary channel with '${plan.amountSats}' sats, fee '${plan.feeSats}'", context = TAG) + executeFunding(plan) + } finally { + operationMutex.unlock() + } + } + + 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 { + 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. */ + fun spliceOutFee(amountSats: ULong, feeRate: ULong): ULong? { + val channel = primaryChannel()?.takeIf { it.isUsable } ?: return null + 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(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) { + operationMutex.withLock { + runSuspendCatching { + val channel = primaryChannel()?.takeIf { it.isUsable } ?: throw PrimaryNodeError.NotConfigured() + if (lightningRepo.hasPendingFundingOperation().getOrThrow()) throw PrimaryWalletBusyError() + 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) { + 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() + } + } + + 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.getPeers().orEmpty() + .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]. + * 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. + */ +internal fun primaryFundingPlan( + spendableSats: ULong, + inputs: ULong, + feeRate: ULong, + hasChannel: Boolean, +): PrimaryFundingPlan? { + 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( + amountSats = amount, + feeSats = fee, + opensChannel = !hasChannel, + isAutomatic = spendableSats >= PrimaryNodeRepo.MIN_FUNDING_SATS && + fee <= spendableSats / 100uL * PrimaryNodeRepo.MAX_FUNDING_FEE_PERCENT, + ) +} + +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. */ +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 (asNodeException()) { + 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 c5e6d14f6e..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 @@ -76,6 +79,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 +90,11 @@ class WalletRepo @Inject constructor( val balanceState = _balanceState.asStateFlow() 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 @@ -386,6 +395,7 @@ class WalletRepo @Inject constructor( syncBalances() lightningRepo.sync().onSuccess { syncBalances() + schedulePrimaryFunding() }.onFailure { if (it is TimeoutCancellationException) { syncBalances() @@ -425,6 +435,33 @@ 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 { + primaryNodeRepo.fundIfNeeded().onFailure { + Logger.warn("Failed to fund primary channel", it, context = TAG) + } } } @@ -439,6 +476,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 @@ -795,7 +835,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) } @@ -929,6 +972,20 @@ 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) } + }, + ) { + approvedPrimaryInvoice = bolt11 to amountSats + runSuspendCatching { setBolt11Locked(bolt11, amountSats, description) } + } + private suspend fun updateBip21InvoiceLocked( amountSats: ULong? = walletState.value.bip21AmountSats, description: String = walletState.value.bip21Description, @@ -945,10 +1002,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 @@ -1014,6 +1076,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/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index c0ff8e83b1..2a72aac972 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -47,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 @@ -71,12 +72,15 @@ 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.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 @@ -206,6 +210,12 @@ 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. */ + 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 private const val DEFAULT_MAX_PATH_COUNT: UByte = 10u @@ -267,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 @@ -342,6 +357,8 @@ class LightningService internal constructor( setScoringFeeParams(scorerFeeParameters()) setAddressType(selectedType) setAddressTypesToMonitor(monitoredTypes) + primaryNode = PrimaryNode.effective(settings.primaryNodeUri) + primaryNode?.let { setPrimaryNode(it.nodeId) } if (channelMigration != null) { setChannelDataMigration(channelMigration) @@ -429,6 +446,7 @@ class LightningService internal constructor( } reconcileWatchOnlyAccountsBestEffort() + primaryNode?.toPeerDetails()?.let { connectPeer(it) } // start event listener after node started onEvent?.let { eventHandler -> @@ -749,7 +767,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 != primaryNode?.nodeId }, channels = node.listChannels(), lightningBalances = balances.lightningBalances, pendingSweeps = balances.pendingBalancesFromChannelClosures, @@ -800,6 +818,55 @@ 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 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, @@ -942,6 +1009,61 @@ class LightningService internal constructor( } } + /** 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 = primaryNode ?: throw PrimaryNodeError.NotConfigured() + + return ServiceQueue.LDK.background(ldkQueue) { + try { + 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 = sat * 1000u, + description = Bolt11InvoiceDescription.Direct(description = description), + expirySecs = PRIMARY_INVOICE_EXPIRY_SECS, + maxFeeMsat = maxFeeMsat, + targetInboundSats = PRIMARY_INBOUND_HEADROOM_SATS, + ).toString() + } catch (e: NodeException) { + throw LdkError(e) + } + } + } + + /** 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 60bd476193..d988004c81 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -145,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 @@ -194,6 +195,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 @@ -1194,13 +1196,14 @@ 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, ) } 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() @@ -1217,8 +1220,9 @@ private fun NavGraphBuilder.home( navController.navigateToTransferSavingsAvailability() } }, - onTransferFromSavingsClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, + onTransferFromSavingsClick = { navController.navigateTo(Routes.PrimaryTransfer) }, onBackClick = { navController.popBackStack() }, + hasPrimaryNode = hasPrimaryNode, ) } deepLinkableComposable { @@ -1674,6 +1678,16 @@ private fun NavGraphBuilder.advancedSettingsSubScreens(navController: NavHostCon deepLinkableComposable { ElectrumConfigScreen(navController) } + deepLinkableComposable { + PrimaryNodeScreen(navController) + } + deepLinkableComposable { + PrimaryTransferScreen( + onBack = { navController.popBackStack() }, + onDone = { navController.popBackStack(Routes.Home, inclusive = false) }, + onSetUpPrimaryNode = { navController.navigateTo(Routes.PrimaryNode) }, + ) + } deepLinkableComposable { RgsServerScreen(navController) } @@ -2189,6 +2203,12 @@ sealed interface Routes { @Serializable data object ElectrumConfig : Routes.DeepLinkable + @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/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/transfer/primary/PrimaryTransferScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt new file mode 100644 index 0000000000..0f08d9d86d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/primary/PrimaryTransferScreen.kt @@ -0,0 +1,129 @@ +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..e5a0736ba6 --- /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(isManual = true) + .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..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 @@ -313,7 +314,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)) @@ -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/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/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 273465d5a0..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 @@ -13,12 +13,14 @@ 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) @@ -45,6 +47,7 @@ class EditInvoiceVM @Inject constructor( source = source, invoiceAmountSats = amountSats, inboundCapacitySats = inboundCapacitySats, + 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..aff102d506 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveConfirmScreen.kt @@ -0,0 +1,166 @@ +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..5c94a6eec7 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/PrimaryReceiveViewModel.kt @@ -0,0 +1,105 @@ +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 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/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index f0de3aaead..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 @@ -70,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() @@ -148,7 +149,16 @@ fun ReceiveSheet( onUsdtSelected = onUsdtSelected, walletState = walletState, lightningState = lightningState, - onClickReceiveCjit = { navController.navigateTo(ReceiveRoute.TransferUnavailable) }, + onClickReceiveCjit = { + if (primaryReceiveViewModel.hasPrimaryNode) { + editInvoiceSourceTab = ReceiveTab.SPENDING + invoiceEditState.beginSoftwareEdit(ReceiveTab.SPENDING) + resetEditInvoiceAmount() + navController.navigateTo(ReceiveRoute.EditInvoice) + } else { + navController.navigateTo(ReceiveRoute.TransferUnavailable) + } + }, onClickEditInvoice = { editInvoiceSourceTab = it invoiceEditState.beginSoftwareEdit(it) @@ -300,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 { @@ -401,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..cf895b245e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeScreen.kt @@ -0,0 +1,198 @@ +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.isInputDefault, + 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, + ), + ) + } +} 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..a4e5cfdefd --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/primaryNode/PrimaryNodeViewModel.kt @@ -0,0 +1,108 @@ +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(), + 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 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/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/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/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/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e39959f5e3..3909cb5e77 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 @@ -153,6 +156,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 @@ -208,6 +212,7 @@ import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo 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 @@ -287,6 +292,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, @@ -378,7 +384,7 @@ class AppViewModel @Inject constructor( requireCurrentPaymentPreparation(walletGeneration) lightningRepo.waitForUsableChannels() requireCurrentPaymentPreparation(walletGeneration) - walletRepo.refreshMaxSendLightning() + lightningRepo.refreshChannelsAndPeers(onRefreshed = walletRepo::refreshMaxSendLightning) requireCurrentPaymentPreparation(walletGeneration) } @@ -2355,8 +2361,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 } @@ -6864,6 +6870,82 @@ 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 -> + addPrimaryChannelSendActivity(txid, address, amount, primaryNodeRepo.spliceOutFeeAt(feeRate)) + 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, + ) + } + + 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 shouldSendFromPrimaryChannel(address: String, amount: ULong): Boolean { + val state = _sendUiState.value + 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, 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( + 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, @@ -7133,6 +7215,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 2c9afacd1f..fb4f940d59 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -36,6 +36,7 @@ import to.bitkit.ext.toUserMessage import to.bitkit.models.Toast 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 +59,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,11 +301,25 @@ 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) + /** + * 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() diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 045e83ac9b..29329ffb4d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -380,6 +380,42 @@ تحديث متاح يرجى تحديث 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..bdf8c42e14 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,42 @@ 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. + 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 + 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..cf839c7a02 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -384,6 +384,42 @@ 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. + 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 + 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..2ad4356c97 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -387,6 +387,42 @@ 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. + 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 + 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..63e7946ec4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -369,6 +369,42 @@ 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. + 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 + Ü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..751cb0d35a 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -380,6 +380,42 @@ Διαθέσιμη ενημέρωση Ενημέρωσε το 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..c6fbe94537 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -386,6 +386,42 @@ 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. + 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 + 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..669bed55c2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -383,6 +383,42 @@ 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. + 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 + 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..d8ead885f9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -368,6 +368,42 @@ 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. + 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é + 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..3f7a48a0e4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -385,6 +385,42 @@ 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. + Bitcoin + 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..dfa91c217c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -384,6 +384,42 @@ 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. + 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 + 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..018806416c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -368,6 +368,42 @@ 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. + 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 + 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..197a2e2787 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -388,6 +388,42 @@ 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. + Bitcoin + 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..a590376465 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -351,6 +351,42 @@ 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. + Bitcoin + 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..6fcae1658a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -398,6 +398,42 @@ Доступно Обновление Пожалуйста, обновите 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/strings.xml b/app/src/main/res/values/strings.xml index e0f1418c67..7b6a1c7d1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -574,6 +574,42 @@ 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. + Bitcoin + 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 + 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\' 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..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( @@ -94,4 +106,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..8f63181c58 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrimaryNodeRepoTest.kt @@ -0,0 +1,76 @@ +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 +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(540uL, plan.feeSats) + assertEquals(49_460uL, 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 = 6uL, 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 = 200uL, 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(540uL, plan.feeSats) + assertEquals(49_460uL, plan.amountSats) + } + + @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() + val wrapped = AppError(LdkError(NodeException.ConnectionFailed(""))).toPrimaryReceiveError() + + assertIs(error) + assertIs(wrapped) + } +} 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/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/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 new file mode 100644 index 0000000000..6e809af9f1 --- /dev/null +++ b/changelog.d/next/1728.added.md @@ -0,0 +1 @@ +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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9767d1a465..2348a0e9eb 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 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" } 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 + + 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 {