From d507b42c1ad26aeef8ab6a4765a0a08ce9351950 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 17:26:45 +0100 Subject: [PATCH 1/6] feat: blocktank sunset --- .../ui/screens/transfer/FundingScreenTest.kt | 14 + .../commands/ReceivedNotificationContent.kt | 3 +- app/src/main/java/to/bitkit/env/Env.kt | 43 +-- .../java/to/bitkit/ext/LightningBalance.kt | 11 + app/src/main/java/to/bitkit/fcm/FcmService.kt | 138 -------- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 298 ------------------ .../models/BlocktankNotificationType.kt | 15 - .../main/java/to/bitkit/models/NodePeer.kt | 2 - .../to/bitkit/repositories/LightningRepo.kt | 76 ++--- .../repositories/OnchainSendAttemptStore.kt | 18 +- .../repositories/PaykitPaymentProofRepo.kt | 9 +- .../java/to/bitkit/services/CoreService.kt | 11 +- .../to/bitkit/services/LightningService.kt | 162 ++++------ .../services/LspNotificationsService.kt | 67 ---- .../to/bitkit/services/MigrationService.kt | 5 +- .../to/bitkit/services/NodeServiceFgState.kt | 4 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 30 +- .../main/java/to/bitkit/ui/MainActivity.kt | 2 + .../main/java/to/bitkit/ui/NodeInfoScreen.kt | 12 +- .../java/to/bitkit/ui/NodeInfoViewModel.kt | 2 - .../java/to/bitkit/ui/components/SheetHost.kt | 1 + .../to/bitkit/ui/components/SheetIntro.kt | 3 +- .../ui/screens/settings/DevSettingsScreen.kt | 15 - .../screens/subscriptions/AllowanceSheet.kt | 7 +- .../subscriptions/CreateSubscriptionScreen.kt | 3 + .../subscriptions/SubscriptionsScreen.kt | 3 + .../ui/screens/transfer/FundingScreen.kt | 64 +++- .../screens/transfer/SavingsProgressScreen.kt | 30 +- .../transfer/external/LnurlChannelScreen.kt | 9 +- .../ui/screens/wallets/SavingsWalletScreen.kt | 28 +- .../wallets/receive/LocationBlockScreen.kt | 58 +--- .../screens/wallets/send/SendPendingScreen.kt | 10 +- .../wallets/send/SendPendingViewModel.kt | 7 + .../BackgroundPaymentsSettings.kt | 4 +- .../ui/sheets/TransferUnavailableSheet.kt | 70 ++++ .../utils/RequestNotificationPermissions.kt | 22 ++ .../main/java/to/bitkit/utils/LoggerLdk.kt | 16 +- .../sheets/NotificationsTimedSheet.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 + .../bitkit/viewmodels/DevSettingsViewModel.kt | 29 -- .../to/bitkit/viewmodels/TransferViewModel.kt | 68 ++-- .../res/drawable/ic_device_tablet_speaker.xml | 18 ++ app/src/main/res/values-ar/strings.xml | 38 +-- app/src/main/res/values-b+es+419/strings.xml | 38 +-- app/src/main/res/values-ca/strings.xml | 38 +-- app/src/main/res/values-cs/strings.xml | 38 +-- app/src/main/res/values-de/strings.xml | 38 +-- app/src/main/res/values-el/strings.xml | 38 +-- app/src/main/res/values-es-rES/strings.xml | 38 +-- app/src/main/res/values-es/strings.xml | 38 +-- app/src/main/res/values-fr/strings.xml | 38 +-- app/src/main/res/values-it/strings.xml | 38 +-- app/src/main/res/values-nl/strings.xml | 38 +-- app/src/main/res/values-pl/strings.xml | 38 +-- app/src/main/res/values-pt-rBR/strings.xml | 38 +-- app/src/main/res/values-pt/strings.xml | 38 +-- app/src/main/res/values-ru/strings.xml | 39 +-- app/src/main/res/values/strings.xml | 39 +-- .../java/to/bitkit/fcm/WakeNodeWorkerTest.kt | 248 --------------- .../bitkit/repositories/LightningRepoTest.kt | 162 +++------- .../OnchainSendAttemptStoreTest.kt | 35 ++ .../PaykitPaymentProofRepoTest.kt | 18 ++ .../bitkit/services/LightningServiceTest.kt | 74 +++++ .../wallets/send/SendPendingViewModelTest.kt | 23 ++ .../test/java/to/bitkit/utils/CryptoTest.kt | 26 +- .../sheets/NotificationsTimedSheetTest.kt | 22 +- .../viewmodels/AppViewModelSendFlowTest.kt | 35 ++ .../viewmodels/TransferViewModelTest.kt | 43 +++ changelog.d/next/1696.changed.md | 1 + docs/timed-sheets.md | 10 +- .../transfer/transfer-unavailable-sheet.xml | 27 ++ 71 files changed, 974 insertions(+), 1761 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt delete mode 100644 app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt delete mode 100644 app/src/main/java/to/bitkit/services/LspNotificationsService.kt create mode 100644 app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt create mode 100644 app/src/main/res/drawable/ic_device_tablet_speaker.xml delete mode 100644 app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt create mode 100644 changelog.d/next/1696.changed.md create mode 100644 journeys/transfer/transfer-unavailable-sheet.xml diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt index a062322016..2397670d96 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt @@ -120,6 +120,20 @@ class FundingScreenTest { assertEquals(0, manualClicks) } + @Test + fun whenGeoBlocked_onlyManualSetupShouldInvoke() { + setContent(isGeoBlocked = true) + + clickableTransferOption().performClick() + composeTestRule.onNodeWithTag("FundReceive").performClick() + composeTestRule.onNodeWithTag("FundManual").performClick() + composeTestRule.waitForIdle() + + assertEquals(0, transferClicks) + assertEquals(0, fundClicks) + assertEquals(1, manualClicks) + } + @Test fun whenFundableBalanceIsZero_transferShouldNotRouteToTheTransferFlow() { setContent(fundableBalance = 0uL) diff --git a/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt b/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt index 822d6a54a5..1b1ea6c721 100644 --- a/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt +++ b/app/src/main/java/to/bitkit/domain/commands/ReceivedNotificationContent.kt @@ -16,8 +16,7 @@ import javax.inject.Singleton /** * Builds the user-facing "Payment Received" notification content, applying the same currency - * formatting rules everywhere a received payment is surfaced (foreground service, in-app handler, - * and the background [to.bitkit.fcm.WakeNodeWorker] push path). + * formatting rules everywhere a received payment is surfaced (foreground service and in-app handler). */ @Singleton class ReceivedNotificationContent @Inject constructor( diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 09797720d0..40427869ec 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -4,13 +4,9 @@ import android.os.Build import com.synonym.bitkitcore.UsdtDestination import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Network -import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.BuildConfig import to.bitkit.BuildConfig.VERSION_NAME import to.bitkit.ext.ensureDir -import to.bitkit.ext.of -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.NodePeer import to.bitkit.utils.Logger import java.io.File import kotlin.io.path.Path @@ -52,14 +48,6 @@ internal object Env { val ldkLogLevel = LogLevel.TRACE - val trustedLnPeers - get() = when (network) { - Network.BITCOIN -> listOf(Peers.lnd1, Peers.lnd3, Peers.lnd4) - Network.REGTEST -> listOf(Peers.stag) - Network.TESTNET -> listOf(Peers.stag) - else -> listOf() - } - /** * Whether LN -> onchain swaps can reach a Boltz backend. Boltz only serves a public API on * mainnet: its testnet deployment is deprecated and regtest resolves to a local backend that @@ -71,9 +59,6 @@ internal object Env { const val fxRateStaleThreshold = 10 * 60 * 1000L // 10 minutes in millis const val lspOrdersRefreshInterval = 2 * 60 * 1000L // 2 minutes in millis - val pushNotificationFeatures = BlocktankNotificationType.entries - const val derivationName = "bitkit-notifications" - const val FILE_PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.fileprovider" const val SUPPORT_EMAIL = "support@synonym.to" const val PIN_LENGTH = 4 @@ -140,11 +125,6 @@ internal object Env { Network.BITCOIN -> "$blocktankBaseUrl/api" else -> "$blocktankBaseUrl/blocktank/api/v2" } - val blocktankNotificationApiUrl - get() = when (network) { - Network.BITCOIN -> "$blocktankBaseUrl/api/notifications" - else -> "$blocktankBaseUrl/notifications/api" - } val btcRatesServer get() = when (network) { @@ -177,6 +157,7 @@ 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" + 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" const val STORING_BITCOINS_URL = "https://en.bitcoin.it/wiki/Storing_bitcoins" @@ -283,28 +264,6 @@ object Defaults { const val TAG_MAX_LENGTH = 20 } -object Peers { - val stag = PeerDetails.of("028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc@34.65.86.104:9400") - val lnd1 = PeerDetails.of("039b8b4dd1d88c2c5db374290cda397a8f5d79f312d6ea5d5bfdfc7c6ff363eae3@34.65.111.104:9735") - val lnd3 = PeerDetails.of("03816141f1dce7782ec32b66a300783b1d436b19777e7c686ed00115bd4b88ff4b@34.65.191.64:9735") - val lnd4 = PeerDetails.of("02a371038863605300d0b3fc9de0cf5ccb57728b7f8906535709a831b16e311187@34.65.153.174:9735") - - object Known { - val stag = NodePeer(Peers.stag, name = "Synonym-Own-Regtest-0") - val lnd1 = NodePeer(Peers.lnd1, name = "Blocktank-LND1") - val lnd3 = NodePeer(Peers.lnd3, name = "Blocktank-LND3") - val lnd4 = NodePeer(Peers.lnd4, name = "Blocktank-LND4") - - fun find(peer: PeerDetails): NodePeer? = when (peer.nodeId) { - stag.peerDetails.nodeId -> stag - lnd1.peerDetails.nodeId -> lnd1 - lnd3.peerDetails.nodeId -> lnd3 - lnd4.peerDetails.nodeId -> lnd4 - else -> null - } - } -} - private object ElectrumServers { object MAINNET { const val ESPLORA = "ssl://bitkit.to:9999" diff --git a/app/src/main/java/to/bitkit/ext/LightningBalance.kt b/app/src/main/java/to/bitkit/ext/LightningBalance.kt index 542f3248ee..e4019b5d26 100644 --- a/app/src/main/java/to/bitkit/ext/LightningBalance.kt +++ b/app/src/main/java/to/bitkit/ext/LightningBalance.kt @@ -24,6 +24,17 @@ fun LightningBalance.channelId(): String { } } +fun LightningBalance.counterpartyNodeId(): String { + return when (this) { + is LightningBalance.ClaimableOnChannelClose -> this.counterpartyNodeId + is LightningBalance.ClaimableAwaitingConfirmations -> this.counterpartyNodeId + is LightningBalance.ContentiousClaimable -> this.counterpartyNodeId + is LightningBalance.MaybeTimeoutClaimableHtlc -> this.counterpartyNodeId + is LightningBalance.MaybePreimageClaimableHtlc -> this.counterpartyNodeId + is LightningBalance.CounterpartyRevokedOutputClaimable -> this.counterpartyNodeId + } +} + fun LightningBalance.claimableAtHeight(): UInt? = when (this) { is LightningBalance.ClaimableAwaitingConfirmations -> this.confirmationHeight is LightningBalance.ContentiousClaimable -> this.timeoutHeight diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index b37a7e655a..e8441718ab 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -2,28 +2,12 @@ package to.bitkit.fcm import android.os.Bundle import androidx.core.os.toPersistableBundle -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.workDataOf import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import dagger.hilt.android.AndroidEntryPoint -import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.JsonObject -import to.bitkit.data.keychain.Keychain -import to.bitkit.di.json -import to.bitkit.env.Env.derivationName -import to.bitkit.ext.fromBase64 -import to.bitkit.ext.fromHex -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.repositories.LightningRepo import to.bitkit.ui.pushNotification -import to.bitkit.utils.Crypto import to.bitkit.utils.Logger import java.util.Date -import javax.inject.Inject @AndroidEntryPoint class FcmService : FirebaseMessagingService() { @@ -31,18 +15,6 @@ class FcmService : FirebaseMessagingService() { private const val TAG = "FcmService" } - private var notificationType: BlocktankNotificationType? = null - private var notificationPayload: JsonObject? = null - - @Inject - lateinit var crypto: Crypto - - @Inject - lateinit var keychain: Keychain - - @Inject - lateinit var lightningRepo: LightningRepo - /** * Act on received messages. [Debug](https://goo.gl/39bRNJ) */ @@ -54,123 +26,13 @@ class FcmService : FirebaseMessagingService() { Logger.debug("FCM body: $body", context = TAG) sendNotification(title, body, Bundle(message.data.toPersistableBundle())) } - - if (message.data.isNotEmpty()) { - Logger.debug("FCM data: ${message.data}", context = TAG) - - val shouldSchedule = runCatching { - val isEncryptedNotification = message.data.tryAs { - decryptPayload(it) - } - isEncryptedNotification - }.getOrElse { - Logger.error("Failed to read encrypted notification payload", it, context = TAG) - // Let the node to spin up and handle incoming events - true - } - - when (shouldSchedule) { - true -> handleAsync() - else -> handleNow(message.data) - } - } - } - - private fun handleAsync() { - val work = OneTimeWorkRequestBuilder() - .setInputData( - workDataOf( - "type" to notificationType?.name, - "payload" to notificationPayload?.toString(), - ) - ) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - WorkManager.getInstance(this) - .beginWith(work) - .enqueue() - } - - private fun handleNow(data: Map) { - Logger.warn("FCM handler not implemented for: $data", context = TAG) - } - - private fun decryptPayload(response: EncryptedNotification) { - val ciphertext = runCatching { response.cipher.fromBase64() }.getOrElse { - Logger.error("Failed to decode cipher", it, context = TAG) - return - } - val privateKey = runCatching { keychain.load(Keychain.Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)!! }.getOrElse { - Logger.error("Missing PUSH_NOTIFICATION_PRIVATE_KEY", it, context = TAG) - return - } - val password = - runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, derivationName) }.getOrElse { - Logger.error("Failed to generate shared secret", it, context = TAG) - return - } - - val decrypted = crypto.decrypt( - encryptedPayload = Crypto.EncryptedPayload(ciphertext, response.iv.fromHex(), response.tag.fromHex()), - secretKey = password, - ) - - val decoded = decrypted.decodeToString() - Logger.debug("Decrypted payload: $decoded", context = TAG) - - val (payload, type) = runCatching { json.decodeFromString(decoded) }.getOrElse { - Logger.error("Failed to decode decrypted data", it, context = TAG) - return - } - - if (payload == null) { - Logger.error("Missing payload", context = TAG) - return - } - - if (type == null) { - Logger.error("Missing type", context = TAG) - return - } - - notificationType = type - notificationPayload = payload } private fun sendNotification(title: String?, body: String?, extras: Bundle? = null) { applicationContext.pushNotification(title, body, extras) } - private inline fun Map.tryAs(block: (T) -> Unit): Boolean { - val encoded = json.encodeToString(this) - return try { - val decoded = json.decodeFromString(encoded) - block(decoded) - true - } catch (_: SerializationException) { - false - } - } - override fun onNewToken(token: String) { Logger.debug("Got new FCM token via FirebaseMessagingService.onNewToken: '$token'", context = TAG) - lightningRepo.registerForNotificationsAsync(token) } } - -@Serializable -data class EncryptedNotification( - val cipher: String, - val iv: String, - val tag: String, - val sound: String = "", - val title: String = "", - val message: String = "", - val publicKey: String = "", -) - -@Serializable -data class DecryptedNotification( - val payload: JsonObject? = null, - val type: BlocktankNotificationType? = null, -) diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt deleted file mode 100644 index 8edf0af18e..0000000000 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ /dev/null @@ -1,298 +0,0 @@ -package to.bitkit.fcm - -import android.content.Context -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import org.lightningdevkit.ldknode.Event -import to.bitkit.App -import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.di.json -import to.bitkit.domain.commands.ReceivedNotificationContent -import to.bitkit.ext.amountOnClose -import to.bitkit.ext.toUserMessage -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived -import to.bitkit.models.BlocktankNotificationType.incomingHtlc -import to.bitkit.models.BlocktankNotificationType.mutualClose -import to.bitkit.models.BlocktankNotificationType.orderPaymentConfirmed -import to.bitkit.models.BlocktankNotificationType.wakeToTimeout -import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NewTransactionSheetDirection -import to.bitkit.models.NewTransactionSheetType -import to.bitkit.models.NotificationDetails -import to.bitkit.models.msatCeilOf -import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.LightningRepo -import to.bitkit.services.NodeServiceFgState -import to.bitkit.ui.pushNotification -import to.bitkit.utils.Logger -import to.bitkit.utils.measured -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds - -@HiltWorker -class WakeNodeWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted private val workerParams: WorkerParameters, - private val lightningRepo: LightningRepo, - private val blocktankRepo: BlocktankRepo, - private val activityRepo: ActivityRepo, - private val cacheStore: CacheStore, - private val receivedNotificationContent: ReceivedNotificationContent, - private val nodeServiceFgState: NodeServiceFgState, -) : CoroutineWorker(appContext, workerParams) { - private var bestAttemptContent: NotificationDetails? = null - - private var notificationType: BlocktankNotificationType? = null - private var notificationPayload: JsonObject? = null - - private val timeout = 2.minutes - private val slowWakeNodeThreshold = 10.seconds - private val deliverSignal = CompletableDeferred() - - override suspend fun doWork(): Result { - Logger.debug("Node wakeup from notification…", context = TAG) - - notificationType = workerParams.inputData.getString("type")?.let { BlocktankNotificationType.valueOf(it) } - notificationPayload = workerParams.inputData.getString("payload")?.let { - runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull() - } - - Logger.debug("notification type: $notificationType", context = TAG) - Logger.debug("notification payload: $notificationPayload", context = TAG) - - if (notificationType == null) { - Logger.warn("Notification type is null, proceeding with node wake", context = TAG) - } - - return runCatching { - measured(label = "doWork", context = TAG, slowThreshold = slowWakeNodeThreshold) { - lightningRepo.start( - walletIndex = 0, - timeout = timeout, - eventHandler = { event -> handleLdkEvent(event) } - ) - - // Once node is started, handle the manual channel opening if needed - if (notificationType == orderPaymentConfirmed) { - val orderId = (notificationPayload?.get("orderId") as? JsonPrimitive)?.contentOrNull - - if (orderId == null) { - Logger.error("Missing orderId", context = TAG) - } else { - Logger.info("Open channel request for order $orderId", context = TAG) - blocktankRepo.openChannel(orderId).onFailure { - Logger.error("Failed to open channel", it, context = TAG) - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__channel_open_failed_title), - body = it.message ?: appContext.getString(R.string.common__error_body), - ) - deliver() - } - } - } - } - // Stops node on timeout & avoids notification replay by OS - withTimeout(timeout) { deliverSignal.await() } - } - .fold( - onSuccess = { Result.success() }, - onFailure = { e -> - if (e is CancellationException) { - Logger.debug("Work cancelled", context = TAG) - return@fold Result.failure(workDataOf("Reason" to "Cancelled")) - } - - val reason = e.message ?: appContext.getString(R.string.common__error_body) - - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__lightning_error_title), - body = reason, - ) - Logger.error("Lightning error", e, context = TAG) - deliver() - - Result.failure(workDataOf("Reason" to reason)) - } - ) - } - - /** - * Listens for LDK events and delivers the notification if the event matches the notification type. - * @param event The LDK event to check. - */ - private suspend fun handleLdkEvent(event: Event) { - when (event) { - is Event.PaymentReceived -> onPaymentReceived(event) - - is Event.ChannelPending -> { - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__channel_opened_title), - body = appContext.getString(R.string.notification__channel_pending_body), - ) - // Don't deliver, give a chance for channelReady event to update the content if it's a turbo channel - } - - is Event.ChannelReady -> onChannelReady(event) - is Event.ChannelClosed -> onChannelClosed(event) - - is Event.PaymentFailed -> { - bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__payment_failed_title), - body = "⚡ ${event.reason.toUserMessage(appContext)}", - ) - - if (notificationType == wakeToTimeout) { - deliver() - } - } - - else -> Unit - } - } - - private suspend fun onChannelClosed(event: Event.ChannelClosed) { - bestAttemptContent = when (notificationType) { - mutualClose -> NotificationDetails( - title = appContext.getString(R.string.notification__channel_closed__title), - body = appContext.getString(R.string.notification__channel_closed__mutual_body), - ) - - orderPaymentConfirmed -> NotificationDetails( - title = appContext.getString(R.string.notification__channel_open_bg_failed_title), - body = appContext.getString(R.string.notification__please_try_again_body), - ) - - else -> NotificationDetails( - title = appContext.getString(R.string.notification__channel_closed__title), - body = appContext.getString(R.string.notification__channel_closed__reason_body, event.reason), - ) - } - - deliver() - } - - private suspend fun onPaymentReceived(event: Event.PaymentReceived) { - val sats = msatCeilOf(event.amountMsat) - // Save for UI to pick up - cacheStore.setBackgroundReceive( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = event.paymentHash, - sats = sats.toLong(), - ) - ) - - // The in-app UI or foreground service shows a richer notification for this event; avoid duplicating it - if (isHandledInProcess()) { - Logger.debug("Skipping payment notification: handled in-process", context = TAG) - bestAttemptContent = null - } else { - bestAttemptContent = receivedNotificationContent.build(sats.toLong()) - } - - if (notificationType == incomingHtlc) { - deliver() - } - } - - private suspend fun onChannelReady(event: Event.ChannelReady) { - when (notificationType) { - cjitPaymentArrived -> onCjitChannelReady(event) - - orderPaymentConfirmed -> bestAttemptContent = NotificationDetails( - title = appContext.getString(R.string.notification__channel_opened_title), - body = appContext.getString(R.string.notification__channel_ready_body), - ) - - else -> Unit - } - deliver() - } - - private suspend fun onCjitChannelReady(event: Event.ChannelReady) { - val channel = lightningRepo.getChannels()?.find { it.channelId == event.channelId } - val cjitEntry = channel?.let { blocktankRepo.getCjitEntry(it) } - - // A regular (non-CJIT) channel opening must not be reported as a received payment - if (channel == null || cjitEntry == null) { - Logger.debug("Skipping CJIT notification: no cjit entry for channel '${event.channelId}'", context = TAG) - bestAttemptContent = null - return - } - - // The in-app UI or foreground service owns this event: it records the activity (via the same - // insertActivityFromCjit dedup) and shows the richer notification/sheet. Defer entirely — - // do NOT insert here, or we'd win the dedup race and silence the in-process handler. - if (isHandledInProcess()) { - Logger.debug("Skipping CJIT notification: handled in-process", context = TAG) - bestAttemptContent = null - return - } - - // A duplicate channel ready event for an already recorded CJIT receive must not notify again - val inserted = activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) - .getOrDefault(false) - if (!inserted) { - Logger.debug("Skipping CJIT notification: already recorded for '${event.channelId}'", context = TAG) - bestAttemptContent = null - return - } - - val sats = channel.amountOnClose - // Save for UI to pick up - cacheStore.setBackgroundReceive( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - sats = sats.toLong(), - ) - ) - - bestAttemptContent = receivedNotificationContent.build(sats.toLong()) - } - - private fun isHandledInProcess(): Boolean = - App.currentActivity?.value != null || nodeServiceFgState.isForegroundServiceRunning - - private suspend fun deliver() { - // Send notification first - bestAttemptContent?.run { - appContext.pushNotification(title, body) - Logger.info("Delivered notification", context = TAG) - } - - // Delay briefly to allow app to come to foreground if user clicked notification - delay(1.seconds) - - // Only stop node if app is not in foreground - // LightningNodeService will keep node running in background when notifications are enabled - if (App.currentActivity?.value == null) { - Logger.debug("App in background, stopping node after notification delivery", context = TAG) - lightningRepo.stop() - } else { - Logger.debug("App in foreground, keeping node running", context = TAG) - } - - deliverSignal.complete(Unit) - } - - companion object { - private const val TAG = "WakeNodeWorker" - } -} diff --git a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt b/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt deleted file mode 100644 index abc02cb537..0000000000 --- a/app/src/main/java/to/bitkit/models/BlocktankNotificationType.kt +++ /dev/null @@ -1,15 +0,0 @@ -package to.bitkit.models - -import kotlinx.serialization.Serializable - -@Suppress("EnumEntryNameCase", "EnumNaming") -@Serializable -enum class BlocktankNotificationType { - incomingHtlc, - mutualClose, - orderPaymentConfirmed, - cjitPaymentArrived, - wakeToTimeout; - - override fun toString(): String = "blocktank.$name" -} diff --git a/app/src/main/java/to/bitkit/models/NodePeer.kt b/app/src/main/java/to/bitkit/models/NodePeer.kt index 584c54ecf3..ba0041844f 100644 --- a/app/src/main/java/to/bitkit/models/NodePeer.kt +++ b/app/src/main/java/to/bitkit/models/NodePeer.kt @@ -7,10 +7,8 @@ import to.bitkit.ext.ellipsisMiddle data class NodePeer( val peerDetails: PeerDetails, val lspNode: ILspNode? = null, - val name: String? = null, ) fun NodePeer.alias(): String = lspNode?.alias - ?: name ?: peerDetails.nodeId.ellipsisMiddle(16) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index d1752edce9..db09d7ac9e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1,7 +1,6 @@ package to.bitkit.repositories import androidx.compose.runtime.Stable -import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.FeeRates @@ -37,7 +36,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.JsonPrimitive @@ -72,7 +70,6 @@ import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching -import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.CoinSelectionPreference @@ -96,7 +93,6 @@ import to.bitkit.services.LightningService import to.bitkit.services.LnurlChannelResponse import to.bitkit.services.LnurlService import to.bitkit.services.LnurlWithdrawResponse -import to.bitkit.services.LspNotificationsService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError @@ -123,8 +119,6 @@ class LightningRepo @Inject constructor( private val lightningService: LightningService, private val settingsStore: SettingsStore, private val coreService: CoreService, - private val lspNotificationsService: LspNotificationsService, - private val firebaseMessaging: FirebaseMessaging, private val keychain: Keychain, private val lnurlService: LnurlService, private val cacheStore: CacheStore, @@ -194,7 +188,6 @@ class LightningRepo @Inject constructor( } if (_lightningState.value.nodeLifecycleState.isRunning()) { - connectToTrustedPeers() scheduleOrdinaryFollowupRetry() } @@ -306,12 +299,10 @@ class LightningRepo @Inject constructor( channelMigration: ChannelDataMigration? = null, ) = withContext(bgDispatcher) { runSuspendCatching { - val trustedPeers = fetchTrustedPeers() lightningService.setup( walletIndex, customServerUrl, customRgsServerUrl, - trustedPeers, channelMigration, ) }.onFailure { @@ -319,16 +310,6 @@ class LightningRepo @Inject constructor( } } - private suspend fun fetchTrustedPeers(): List? = runSuspendCatching { - val info = coreService.blocktank.info(refresh = false) - ?: coreService.blocktank.info(refresh = true) - info?.nodes?.toPeerDetailsList()?.also { - Logger.info("Fetched ${it.size} trusted peers from remote", context = TAG) - } - }.onFailure { - Logger.warn("fetchTrustedPeers error", it, context = TAG) - }.getOrNull() - suspend fun start( walletIndex: Int = 0, timeout: Duration? = null, @@ -368,6 +349,9 @@ class LightningRepo @Inject constructor( if (shouldCancelPendingStop) cancelPendingStop() + // The geocheck needs no node, so screens gated on it are right before the node is up. + scope.launch { updateGeoBlockState() } + eventHandler?.let { _eventHandlers.add(it) } // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) @@ -436,15 +420,14 @@ class LightningRepo @Inject constructor( } // Post-startup tasks (non-blocking) - connectToTrustedPeers().onFailure { - Logger.error("Failed to connect to trusted peers", it, context = TAG) + disconnectIdlePeers().onFailure { + Logger.error("Failed to disconnect idle peers", it, context = TAG) } - logNodeSupportSummary("trusted peers connected") + logNodeSupportSummary("idle peers disconnected") sync().onFailure { e -> Logger.warn("Initial sync failed, event-driven sync will retry", e, context = TAG) } - scope.launch { registerForNotifications() } Result.success(Unit) }.getOrElse { e -> if (e is CancellationException) { @@ -672,6 +655,13 @@ class LightningRepo @Inject constructor( fun setWiping(enabled: Boolean) = run { isWiping = enabled } + /** Blocks a transfer to spending unless the geocheck confirms the region is allowed. */ + suspend fun isTransferBlocked(): Boolean = withContext(bgDispatcher) { + coreService.isGeoBlocked(blockOnError = true).also { blocked -> + _lightningState.update { it.copy(isGeoBlocked = blocked) } + } + } + suspend fun updateGeoBlockState() = withContext(bgDispatcher) { _lightningState.update { it.copy(isGeoBlocked = coreService.isGeoBlocked()) @@ -726,7 +716,9 @@ class LightningRepo @Inject constructor( _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopping) } lightningService.stop() clearProbeOutcomes() - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + _lightningState.update { + LightningState(nodeLifecycleState = NodeLifecycleState.Stopped, isGeoBlocked = it.isGeoBlocked) + } } }.onFailure { Logger.error("Node stop error", it, context = TAG) @@ -737,7 +729,9 @@ class LightningRepo @Inject constructor( _lightningState.update { s -> s.copy(nodeLifecycleState = NodeLifecycleState.Running) } } else { // Node appears stopped, update state - _lightningState.update { LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } + _lightningState.update { + LightningState(nodeLifecycleState = NodeLifecycleState.Stopped, isGeoBlocked = it.isGeoBlocked) + } } } } @@ -1255,8 +1249,8 @@ class LightningRepo @Inject constructor( return@withContext Result.success(Unit) } - suspend fun connectToTrustedPeers(): Result = executeWhenNodeRunning("connectToTrustedPeers") { - runCatching { lightningService.connectToTrustedPeers() }.also { + suspend fun disconnectIdlePeers(): Result = executeWhenNodeRunning("disconnectIdlePeers") { + runSuspendCatching { lightningService.disconnectIdlePeers() }.also { syncState() } } @@ -2247,34 +2241,6 @@ class LightningRepo @Inject constructor( return isRunning && lightningService.canReceive() } - fun separateTrustedChannels(channels: List) = lightningService.separateTrustedChannels(channels) - - @Suppress("KotlinConstantConditions") - suspend fun registerForNotifications(token: String? = null) = executeWhenNodeRunning("registerForNotifications") { - if (Env.isE2eTest) { - Logger.info("Skipped push registration in E2E build", context = TAG) - return@executeWhenNodeRunning Result.success(Unit) - } - - runCatching { - val token = token ?: firebaseMessaging.token.await() - val cachedToken = keychain.loadString(Keychain.Key.PUSH_NOTIFICATION_TOKEN.name) - - require(token.isNotEmpty()) { "FCM token is empty or null" } - - if (cachedToken == token) { - Logger.debug("registerForNotifications skipped, device token already registered") - return@executeWhenNodeRunning Result.success(Unit) - } - - lspNotificationsService.registerDevice(token) - }.onFailure { - Logger.error("registerForNotifications error", it, context = TAG) - } - } - - fun registerForNotificationsAsync(token: String) = scope.launch { registerForNotifications(token) } - suspend fun bumpFeeByRbf( originalTxId: Txid, satsPerVByte: ULong, diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 1290a4731c..f05091b456 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -120,11 +120,21 @@ data class OnchainSendAttempt( val highestCandidateFee: ULong get() = maxOf(candidateMiningFees.values.maxOrNull() ?: 0uL, verifiedWinningFeeSats ?: 0uL) - /** A Shop payment from the node wallet that dispatched but never had its acceptance confirmed. */ + /** A transfer to spending that pays a Blocktank order, which Bitkit no longer buys. */ + val isBlocktankFunding: Boolean + get() = requestId == null && isTransfer && orderId != null + + /** + * A Shop payment, or a Blocktank funding, from the node wallet that dispatched but never had its acceptance + * confirmed. + */ val canBeAbandoned: Boolean - get() = isUnresolved && requestId != null && !isTransfer && - walletId == WalletScope.default && !payerIdentity.isNullOrBlank() && initialDispatchAttempted && - txid != null && !originalInputs.isNullOrEmpty() && candidateTxids.isNotEmpty() + get() { + val isShopPayment = requestId != null && !isTransfer && !payerIdentity.isNullOrBlank() + return isUnresolved && (isShopPayment || isBlocktankFunding) && walletId == WalletScope.default && + initialDispatchAttempted && txid != null && !originalInputs.isNullOrEmpty() && + candidateTxids.isNotEmpty() + } } class OnchainSendBlockedError(val attempt: OnchainSendAttempt? = null) : diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 4a5b66f367..4ef0a86315 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -443,6 +443,11 @@ class PaykitPaymentProofRepo @Inject constructor( * deadline passed. A record that is missing, carries a proof or needs recovery never counts as ended. */ suspend fun onchainRequestHasEnded(attempt: OnchainSendAttempt): Boolean = withContext(ioDispatcher) { + // Blocktank no longer sells channels, so its order ends once transfers are unavailable. + if (attempt.isBlocktankFunding) { + return@withContext attempt.canBeAbandoned && + (attempt.releaseApproved || lightningRepo.isTransferBlocked()) + } val identity = currentIdentity()?.takeIf { PubkyPublicKeyFormat.matches(it, attempt.payerIdentity) } val record = attempt.takeIf { it.canBeAbandoned && identity != null }?.let { payerRecord(it) } val periodStartsAt = attempt.requestId?.billingPeriodStartsAt @@ -586,7 +591,9 @@ class PaykitPaymentProofRepo @Inject constructor( /** Removes the original proof, then the attempt, once its release reached the network. */ private suspend fun finishRelease(attempt: OnchainSendAttempt) { - if (!lightningRepo.abandonUnbroadcastOnchainSend(attempt) { removeOriginalShopProofs(it) }) { + val removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean = + { if (it.isBlocktankFunding) true else removeOriginalShopProofs(it) } + if (!lightningRepo.abandonUnbroadcastOnchainSend(attempt, removeOriginalProof)) { throw OnchainSendBlockedError(attempt) } Logger.info( diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 445365a6d4..68e09e837c 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -172,8 +172,9 @@ class CoreService @Inject constructor( } } + /** @param blockOnError whether a failed or unexpected geocheck counts as blocked. */ @Suppress("KotlinConstantConditions") - suspend fun isGeoBlocked(): Boolean { + suspend fun isGeoBlocked(blockOnError: Boolean = false): Boolean { val tag = "GeoCheck" if (!Env.isGeoblockingEnabled) { Logger.verbose("Geoblocking disabled via build config", context = tag) @@ -198,15 +199,15 @@ class CoreService @Inject constructor( else -> { Logger.warn( - "Unexpected status code: ${response.status.value}, defaulting to false", + "Unexpected status code: ${response.status.value}, defaulting to '$blockOnError'", context = tag ) - false + blockOnError } } }.onFailure { - Logger.warn("Error. defaulting isGeoBlocked to false", context = tag) - }.getOrDefault(false) + Logger.warn("Failed geocheck, defaulting isGeoBlocked to '$blockOnError'", it, context = tag) + }.getOrDefault(blockOnError) } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 576f39e576..876a979d87 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -35,6 +35,7 @@ import org.lightningdevkit.ldknode.ElectrumSyncConfig import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind +import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -45,6 +46,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PeerDetails +import org.lightningdevkit.ldknode.PendingSweepBalance import org.lightningdevkit.ldknode.PublicKey import org.lightningdevkit.ldknode.RouteParametersConfig import org.lightningdevkit.ldknode.ScoringFeeParameters @@ -63,6 +65,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.counterpartyNodeId import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.uByteList import to.bitkit.ext.uri @@ -107,6 +110,40 @@ private class EventListenerContext : AbstractCoroutineContextElement(Key) { companion object Key : CoroutineContext.Key } +/** + * Builds the node config. LSP peers are not trusted for zero-conf channels or to broadcast our force + * closes: Blocktank no longer serves channels, so our node must broadcast and fee-bump its own closes. + */ +internal fun nodeConfig( + base: Config, + storageDirPath: String, + onchainWalletAccounts: List, +): Config = base.copy( + storageDirPath = storageDirPath, + network = Env.network, + trustedPeers0conf = emptyList(), + anchorChannelsConfig = AnchorChannelsConfig( + trustedPeersNoReserve = emptyList(), + perChannelReserveSats = 1u, + ), + probingLiquidityLimitMultiplier = 1uL, + includeUntrustedPendingInSpendable = true, + onchainWalletAccounts = onchainWalletAccounts, +) + +/** Returns the stored peers with no channel and no claimable balance, or none while any sweep is pending. */ +internal fun idlePeerIds( + peers: List, + channels: List, + lightningBalances: List, + pendingSweeps: List, +): List { + if (pendingSweeps.isNotEmpty()) return emptyList() + val busyPeerIds = channels.map { it.counterpartyNodeId }.toSet() + + lightningBalances.map { it.counterpartyNodeId() } + return peers.filter { it.isPersisted && it.nodeId !in busyPeerIds }.map { it.nodeId } +} + internal fun enabledOnchainWalletAccountConfigs( records: List, walletIndex: Int, @@ -235,8 +272,6 @@ class LightningService internal constructor( private val _syncStatusChanged = MutableSharedFlow(extraBufferCapacity = 1) val syncStatusChanged: SharedFlow = _syncStatusChanged.asSharedFlow() - private var trustedPeers: List = emptyList() - private var listenerJob: Job? = null // Release of the previous node's rust handle (free_node). Rebuild and destructive storage work @@ -248,13 +283,12 @@ class LightningService internal constructor( walletIndex: Int, customServerUrl: String? = null, customRgsServerUrl: String? = null, - trustedPeers: List? = null, channelMigration: ChannelDataMigration? = null, ) { awaitNodeRelease() Logger.debug("Building node…", context = TAG) - val config = config(walletIndex, trustedPeers) + val config = config(walletIndex) val builtNode = build( walletIndex, customServerUrl, @@ -268,28 +302,12 @@ class LightningService internal constructor( Logger.info("LDK node setup", context = TAG) } - private suspend fun config( - walletIndex: Int, - trustedPeers: List?, - ): Config { + private suspend fun config(walletIndex: Int): Config { val dirPath = Env.ldkStoragePath(walletIndex) - this.trustedPeers = trustedPeers?.takeIf { it.isNotEmpty() } ?: Env.trustedLnPeers.also { - Logger.warn("Missing trusted peers from LSP, falling back to preconfigured env peers", context = TAG) - } - - val trustedPeerNodeIds = this.trustedPeers.map { it.nodeId } - - return defaultConfig().copy( + return nodeConfig( + base = defaultConfig(), storageDirPath = dirPath, - network = Env.network, - trustedPeers0conf = trustedPeerNodeIds, - anchorChannelsConfig = AnchorChannelsConfig( - trustedPeersNoReserve = trustedPeerNodeIds, - perChannelReserveSats = 1u, - ), - probingLiquidityLimitMultiplier = 1uL, - includeUntrustedPendingInSpendable = true, onchainWalletAccounts = enabledOnchainWalletAccountConfigs(watchOnlyAccountStore.load(), walletIndex), ) } @@ -604,20 +622,6 @@ class LightningService internal constructor( return true } - // reset graph if missing trusted peers - val missing = trustedPeers.filter { it.nodeId !in nodes } - if (missing.size == trustedPeers.size) { - val rgsTimestamp = node.status().latestRgsSnapshotTimestamp - val missingIds = missing.joinToString { it.nodeId.take(NODE_ID_PREVIEW_LEN) } - Logger.warn( - "Network graph missing all ${trustedPeers.size} trusted peers: [$missingIds] " + - "(graphNodes=${nodes.size}, channels=${graph.listChannels().size}, " + - "rgsTimestamp=$rgsTimestamp)", - context = TAG, - ) - return false - } - // reset graph if missing channel counterparty val channels = node.listChannels().filter { it.isUsable } val missingCounterparties = channels.filter { it.counterpartyNodeId !in nodes } @@ -631,20 +635,7 @@ class LightningService internal constructor( return false } - if (missing.isNotEmpty()) { - val ids = missing.joinToString { it.nodeId } - Logger.debug( - "Network graph missing ${missing.size}/${trustedPeers.size} trusted peers: [$ids]", - context = TAG, - ) - } - - val total = trustedPeers.size - val count = total - missing.size - Logger.debug( - "Network graph validated: $count/$total trusted peers present", - context = TAG, - ) + Logger.debug("Network graph validated: '${channels.size}' channel counterparties present", context = TAG) return true } @@ -744,39 +735,26 @@ class LightningService internal constructor( } // region peers - suspend fun connectToTrustedPeers() { + /** + * Disconnects stored peers we have nothing pending with, which also drops them from the peer store. + * Skips the run while any closed channel still awaits its sweep, as a sweep does not name its peer. + */ + suspend fun disconnectIdlePeers() { val node = this.node ?: throw ServiceError.NodeNotSetup() ServiceQueue.LDK.background(ldkQueue) { - for (peer in trustedPeers) { - try { - node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Connected to trusted peer: $peer", context = TAG) - } catch (e: NodeException) { - Logger.error("Peer connect error: $peer", LdkError(e), context = TAG) - } - } - - verifyTrustedPeersOrFallback(node) - } - } - - private fun verifyTrustedPeersOrFallback(node: Node) { - val connectedPeerIds = node.listPeers().map { it.nodeId }.toSet() - val trustedConnected = trustedPeers.count { it.nodeId in connectedPeerIds } - - if (trustedConnected == 0 && trustedPeers.isNotEmpty()) { - Logger.warn("No trusted peers connected, falling back to preconfigured env peers", context = TAG) - for (peer in Env.trustedLnPeers) { - try { - node.connect(peer.nodeId, peer.address, persist = true) - Logger.info("Connected to fallback peer: $peer", context = TAG) - } catch (e: NodeException) { - Logger.error("Fallback peer connect error: $peer", LdkError(e), context = TAG) - } + val balances = node.listBalances() + val idlePeerIds = idlePeerIds( + peers = node.listPeers(), + channels = node.listChannels(), + lightningBalances = balances.lightningBalances, + pendingSweeps = balances.pendingBalancesFromChannelClosures, + ) + for (nodeId in idlePeerIds) { + runCatching { node.disconnect(nodeId) } + .onSuccess { Logger.info("Disconnected idle peer '$nodeId'", context = TAG) } + .onFailure { Logger.warn("Failed to disconnect idle peer '$nodeId'", it, context = TAG) } } - } else { - Logger.info("Connected to $trustedConnected/${trustedPeers.size} trusted peers", context = TAG) } } @@ -815,17 +793,6 @@ class LightningService internal constructor( } } - fun getLspPeerNodeIds(): Set = trustedPeers.map { it.nodeId }.toSet() - - fun separateTrustedChannels(channels: List): Pair, List> { - val trustedPeerIds = getLspPeerNodeIds() - val trusted = channels.filter { it.counterpartyNodeId in trustedPeerIds } - val nonTrusted = channels.filter { it.counterpartyNodeId !in trustedPeerIds } - return trusted to nonTrusted - } - - fun isTrustedPeer(nodeId: String): Boolean = nodeId in getLspPeerNodeIds() - // endregion // region channels @@ -879,11 +846,6 @@ class LightningService internal constructor( val userChannelId = channel.userChannelId val counterpartyNodeId = channel.counterpartyNodeId - // Prevent force closing channels with trusted peers (LSP nodes) - if (force && isTrustedPeer(counterpartyNodeId)) { - throw TrustedPeerForceCloseException() - } - try { ServiceQueue.LDK.background(ldkQueue) { Logger.debug("Initiating channel close (force=$force): '$channelId'", context = TAG) @@ -1151,7 +1113,7 @@ class LightningService internal constructor( } } }.onFailure { - loggerLdk.dumpNetworkGraphInfo(node, trustedPeers, bolt11) + loggerLdk.dumpNetworkGraphInfo(node, bolt11) }.getOrThrow() } @@ -1556,7 +1518,7 @@ class LightningService internal constructor( private fun dumpNetworkGraphInfo(bolt11: String) { val node = this.node ?: return - loggerLdk.dumpNetworkGraphInfo(node, trustedPeers, bolt11) + loggerLdk.dumpNetworkGraphInfo(node, bolt11) } suspend fun exportNetworkGraphToFile( @@ -1577,10 +1539,6 @@ data class NetworkGraphInfo( val latestRgsSyncTimestamp: ULong?, ) -class TrustedPeerForceCloseException : AppError( - "Cannot force close channel with trusted peer. Force close is disabled for Blocktank LSP channels." -) - class NetworkGraphCacheDeleteError : AppError("Failed to delete network graph cache") /** Exact signed transaction inputs/prevouts, never an estimate or a backend-absence inference. */ diff --git a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt b/app/src/main/java/to/bitkit/services/LspNotificationsService.kt deleted file mode 100644 index 955b775d92..0000000000 --- a/app/src/main/java/to/bitkit/services/LspNotificationsService.kt +++ /dev/null @@ -1,67 +0,0 @@ -package to.bitkit.services - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import to.bitkit.async.ServiceQueue -import to.bitkit.data.keychain.Keychain -import to.bitkit.data.keychain.Keychain.Key -import to.bitkit.di.BgDispatcher -import to.bitkit.env.Env -import to.bitkit.env.Env.derivationName -import to.bitkit.ext.nowTimestamp -import to.bitkit.ext.toHex -import to.bitkit.utils.Crypto -import to.bitkit.utils.Logger -import to.bitkit.utils.ServiceError -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class LspNotificationsService @Inject constructor( - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val lightningService: LightningService, - private val keychain: Keychain, - private val crypto: Crypto, -) { - suspend fun registerDevice(deviceToken: String) = withContext(bgDispatcher) { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() - - Logger.debug("Registering device for notifications…") - - val timestamp = nowTimestamp() - val messageToSign = "$derivationName$deviceToken$timestamp" - - val signature = lightningService.sign(messageToSign) - - val keypair = crypto.generateKeyPair() - val publicKey = keypair.publicKey.toHex() - Logger.debug("Notification encryption public key: $publicKey") - - // New keypair for each token registration - if (keychain.exists(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)) { - keychain.delete(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name) - } - keychain.save(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name, keypair.privateKey) - - ServiceQueue.CORE.background { - com.synonym.bitkitcore.registerDevice( - deviceToken = deviceToken, - publicKey = publicKey, - features = Env.pushNotificationFeatures.map { it.toString() }, - nodeId = nodeId, - isoTimestamp = "$timestamp", - signature = signature, - customUrl = Env.blocktankNotificationApiUrl, - isProduction = !Env.isDebug, - ) - } - - // Cache token so we can avoid re-registering - if (keychain.exists(Key.PUSH_NOTIFICATION_TOKEN.name)) { - keychain.delete(Key.PUSH_NOTIFICATION_TOKEN.name) - } - keychain.saveString(Key.PUSH_NOTIFICATION_TOKEN.name, deviceToken) - - Logger.info("Device registered for notifications") - } -} diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index eaa3af3c4f..352e7f7951 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -1451,10 +1451,7 @@ class MigrationService internal constructor( val peers = json.decodeFromString>(String(data)) if (peers.isEmpty()) return emptyList() - val trustedIds = Env.trustedLnPeers.map { it.nodeId }.toSet() - val uris = peers - .filter { it.pubKey !in trustedIds } - .map { "${it.pubKey}@${it.address}:${it.port}" } + val uris = peers.map { "${it.pubKey}@${it.address}:${it.port}" } Logger.info("Migration peer recovery: fetched ${uris.size} peer(s) from remote backup", context = TAG) uris diff --git a/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt b/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt index cc69b35d84..588d892e28 100644 --- a/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt +++ b/app/src/main/java/to/bitkit/services/NodeServiceFgState.kt @@ -5,9 +5,7 @@ import javax.inject.Singleton /** * Process-wide flag tracking whether [to.bitkit.androidServices.LightningNodeService] is running as a - * foreground service. Used to coordinate notification ownership: when the foreground service is alive - * it handles LDK events in-process, so the background [to.bitkit.fcm.WakeNodeWorker] defers to it - * instead of posting its own (duplicate) notification. + * foreground service. When it is alive it handles LDK events in-process. */ @Singleton class NodeServiceFgState @Inject constructor() { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index fff4a35de1..639629c5d1 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -226,6 +226,7 @@ import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QrScanningSheet import to.bitkit.ui.sheets.QuickPayIntroSheet import to.bitkit.ui.sheets.SendSheet +import to.bitkit.ui.sheets.TransferUnavailableSheet import to.bitkit.ui.sheets.UpdateSheet import to.bitkit.ui.sheets.UsdtIntroSheet import to.bitkit.ui.sheets.WidgetsSheet @@ -656,6 +657,12 @@ fun ContentView( Sheet.ConnectionClosed -> ConnectionClosedSheet( onDismiss = { appViewModel.hideSheet() }, ) + Sheet.TransferUnavailable -> TransferUnavailableSheet( + onContinue = { + navController.navigateTo(ExternalConnection()) + appViewModel.hideSheet() + }, + ) is Sheet.BTCPayConnection -> BTCPayConnectionSheet(sheet, appViewModel) is Sheet.Gift -> GiftSheet(sheet, appViewModel) @@ -884,6 +891,8 @@ private fun RootNavHost( transferViewModel.transferEffects.collect { effect -> if (effect is TransferEffect.OnFundingPending) { appViewModel.showPendingTransfer(effect.attempt) + } else if (effect is TransferEffect.OnTransferUnavailable) { + appViewModel.showSheet(Sheet.TransferUnavailable) } else { navController.navigateForTransferEffect(effect) } @@ -1298,13 +1307,16 @@ private fun NavGraphBuilder.home( val forceCloseRemainingDuration by appViewModel.forceCloseRemainingDuration.collectAsStateWithLifecycle() SavingsWalletScreen( - isGeoBlocked = isGeoBlocked, onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + if (isGeoBlocked) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + } }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, @@ -1313,6 +1325,7 @@ private fun NavGraphBuilder.home( deepLinkableComposable { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() + val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() @@ -1330,7 +1343,11 @@ private fun NavGraphBuilder.home( } }, onTransferFromSavingsClick = { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + if (isGeoBlocked) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + } }, onBackClick = { navController.popBackStack() }, ) @@ -1338,11 +1355,16 @@ private fun NavGraphBuilder.home( deepLinkableComposable { val walletId = it.toRoute().walletId val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() + val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() HardwareWalletScreen( walletId = walletId, onActivityItemClick = { navController.navToActivityDetail(it) }, onTransferToSpendingClick = { selectedDeviceId -> - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) + if (isGeoBlocked) { + appViewModel.showSheet(Sheet.TransferUnavailable) + } else { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) + } }, onBackClick = { navController.popBackStack() }, ) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 2f2b47d664..b98222b557 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -348,6 +348,8 @@ class MainActivity : FragmentActivity() { override fun onResume() { super.onResume() appViewModel.onAppResumed() + // A permission dialog launched from a sheet that has since closed loses its result callback. + settingsViewModel.setNotificationPreference(areNotificationsEnabled()) } override fun onStop() { diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 814325d29e..9b8eec4123 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -42,13 +42,13 @@ import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.env.Peers import to.bitkit.ext.amountSats import to.bitkit.ext.balanceUiText import to.bitkit.ext.channelId import to.bitkit.ext.createChannelDetails import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString +import to.bitkit.ext.of import to.bitkit.ext.uri import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodePeer @@ -476,10 +476,10 @@ private fun ChannelDetailRow( private fun previewPeers() = persistentListOf( NodePeer( - peerDetails = Peers.stag, + peerDetails = previewPeer, lspNode = ILspNode( alias = "Blocktank-LND1", - pubkey = Peers.stag.nodeId, + pubkey = previewPeer.nodeId, connectionStrings = listOf(), readonly = null, ), @@ -532,7 +532,7 @@ private fun Preview() { latestPathfindingScoresSyncTimestamp = null, ), nodeId = "0348a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9", - peers = persistentListOf(Peers.stag), + peers = persistentListOf(previewPeer), channels = persistentListOf( createChannelDetails().copy( channelId = "abc123def456789012345678901234567890123456789012345678901234567890", @@ -600,3 +600,7 @@ private fun Preview() { ) } } + +private val previewPeer = PeerDetails.of( + "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9@127.0.0.1:9735" +) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt b/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt index 6eaf854415..1dae06ace5 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoViewModel.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.env.Peers import to.bitkit.models.NodePeer import to.bitkit.models.Toast import to.bitkit.models.alias @@ -47,7 +46,6 @@ class NodeInfoViewModel @Inject constructor( NodePeer( peerDetails = peer, lspNode = lspNodes?.firstOrNull { it.pubkey == peer.nodeId }, - name = Peers.Known.find(peer)?.name, ) }.sortedBy { it.alias() }.toImmutableList() } diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index ff1b102332..18c366380e 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -98,6 +98,7 @@ sealed interface Sheet { data object ForceTransfer : Sheet data class Gift(val code: String, val amount: ULong) : Sheet data object ConnectionClosed : Sheet + data object TransferUnavailable : Sheet data class BTCPayConnection( val setup: SamRockSetupRequest, val isConnecting: Boolean = false, diff --git a/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt b/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt index a4a029e3b9..22face51ba 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetIntro.kt @@ -38,6 +38,7 @@ fun SheetIntro( continueLoading: Boolean = false, cancelText: String? = null, onCancel: (() -> Unit)? = null, + onBack: (() -> Unit)? = null, testTag: String = "SheetIntro", cancelTestTag: String = "${testTag}Cancel", continueTestTag: String = "${testTag}Continue", @@ -49,7 +50,7 @@ fun SheetIntro( .navigationBarsPadding() .testTag(testTag) ) { - SheetTopBar(navTitle) + SheetTopBar(navTitle, onBack = onBack) Column( modifier = Modifier.padding(horizontal = 32.dp), diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 657fec0fc4..68d5499331 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -241,21 +241,6 @@ fun DevSettingsScreen( viewModel.openChannel() } ) - - SectionHeader("NOTIFICATIONS") - - SettingsTextButtonRow( - title = "Register For LSP Notifications", - onClick = { - viewModel.registerForNotifications() - } - ) - SettingsTextButtonRow( - title = "Test LSP Notification", - onClick = { - viewModel.testLspNotification() - } - ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt index 6bc695b0fa..fc91ca97dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowanceSheet.kt @@ -68,6 +68,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AppViewModel import kotlin.time.Duration.Companion.seconds @@ -85,6 +86,7 @@ fun AllowanceSheet( viewModel: AllowancesViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val requestNotifications = rememberSubscriptionNotificationsRequest() // The view model outlives this sheet, so a check left open must never run in the next one. DisposableEffect(route) { onDispose { viewModel.onAuthorizationCancelled() } @@ -128,7 +130,10 @@ fun AllowanceSheet( allowance = allowance, isWorking = uiState.isWorking, onClickDecline = { viewModel.decline(allowance.id, onSuccess = appViewModel::hideSheet) }, - onClickAccept = { viewModel.accept(allowance.id, onSuccess = appViewModel::hideSheet) }, + onClickAccept = { + requestNotifications() + viewModel.accept(allowance.id, onSuccess = appViewModel::hideSheet) + }, modifier = Modifier.sheetHeight() ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt index 21591fec10..8896558436 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -110,6 +110,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest import to.bitkit.ui.utils.withAccent import to.bitkit.utils.SubscriptionClockOffset import to.bitkit.utils.SubscriptionIcon @@ -127,6 +128,7 @@ fun CreateSubscriptionSheet( val resources = LocalResources.current val currencies = LocalCurrencies.current val scope = rememberCoroutineScope() + val requestNotifications = rememberSubscriptionNotificationsRequest() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() @@ -242,6 +244,7 @@ fun CreateSubscriptionSheet( onSelected = { selectedTarget = it }, onExpirationChange = { expiration = it }, onPropose = { + requestNotifications() selectedTarget?.let { target -> appViewModel.createSubscription( draft = PaykitSubscriptionDraft( diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index a76e5a551a..70a851e4cb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -118,6 +118,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.sheets.TransactionConfetti import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent import to.bitkit.utils.SubscriptionClockOffset @@ -697,6 +698,7 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout val subscription = subscriptions.firstOrNull { it.id == route.id } val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val now = rememberSubscriptionNow(listOfNotNull(subscription).toImmutableList()) + val requestNotifications = rememberSubscriptionNotificationsRequest() LaunchedEffect(route, subscription, isProcessing) { val proposalIsUnavailable = route is SubscriptionRoute.Review && @@ -734,6 +736,7 @@ fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRout } }, onSubscribe = { + requestNotifications() isProcessing = true appViewModel.acceptSubscriptionAndStartPayment(subscription).fold( onSuccess = { startedPayment -> diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt index 39f276e3b0..1ff681448e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.transfer +import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,16 +14,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import to.bitkit.R import to.bitkit.env.Defaults +import to.bitkit.env.Env import to.bitkit.ui.LocalBalances import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.RectangleButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -62,7 +69,7 @@ fun FundingScreen( Spacer(modifier = Modifier.height(8.dp)) val text = if (isGeoBlocked) { - stringResource(R.string.lightning__funding__text_blocked) + stringResource(R.string.lightning__transfer_unavailable__text) } else { stringResource(R.string.lightning__funding__text) } @@ -70,9 +77,20 @@ fun FundingScreen( Spacer(modifier = Modifier.height(32.dp)) + val manualButton = @Composable { + RectangleButton( + label = stringResource(R.string.lightning__funding_advanced__button2), + icon = R.drawable.ic_pencil_full, + iconTint = Colors.Purple, + iconSize = 13.37.dp, + onClick = onManual, + modifier = Modifier.testTag("FundManual") + ) + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { + if (isGeoBlocked) manualButton() Box { RectangleButton( label = stringResource(R.string.lightning__funding__button1), @@ -87,12 +105,23 @@ fun FundingScreen( .matchParentSize() .clickableAlpha( pressedAlpha = 1f, - enabled = balances.channelFundableBalance == 0uL, + enabled = balances.channelFundableBalance == 0uL && !isGeoBlocked, onClick = { showNoFundsAlert = true }, ) .testTag("FundTransfer") ) } + if (isGeoBlocked) { + RectangleButton( + label = stringResource(R.string.lightning__funding__button_hw), + icon = R.drawable.ic_device_tablet_speaker, + iconTint = Colors.Purple, + iconSize = 16.25.dp, + enabled = false, + onClick = {}, + modifier = Modifier.testTag("FundHardware") + ) + } RectangleButton( label = stringResource(R.string.lightning__funding__button2), icon = R.drawable.ic_qr_purple, @@ -102,16 +131,23 @@ fun FundingScreen( onClick = onFund, modifier = Modifier.testTag("FundReceive") ) - RectangleButton( - label = stringResource(R.string.lightning__funding_advanced__button2), - icon = R.drawable.ic_pencil_full, - iconTint = Colors.Purple, - iconSize = 13.37.dp, - onClick = onManual, - modifier = Modifier.testTag("FundManual") - ) + if (!isGeoBlocked) manualButton() } } + if (isGeoBlocked) { + val context = LocalContext.current + FillHeight() + SecondaryButton( + text = stringResource(R.string.common__learn_more), + onClick = { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Env.BLOCKTANK_SUNSET_URL.toUri())) } + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("FundLearnMore") + ) + VerticalSpacer(16.dp) + } if (showNoFundsAlert) { AppAlertDialog( title = stringResource(R.string.lightning__no_funds__title), @@ -135,3 +171,11 @@ private fun FundingScreenPreview() { FundingScreen(isGeoBlocked = false) } } + +@Preview(showSystemUi = true) +@Composable +private fun FundingScreenGeoBlockedPreview() { + AppThemeSurface { + FundingScreen(isGeoBlocked = true) + } +} 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 d408960dba..cdf45cacdc 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 @@ -76,14 +76,6 @@ fun SavingsProgressScreen( onSuccess = { progressState = SavingsProgressState.SUCCESS }, onInterrupted = { progressState = SavingsProgressState.INTERRUPTED }, onGiveUp = { app.showSheet(Sheet.ForceTransfer) }, - onUnavailable = { - app.toast( - type = Toast.ToastType.ERROR, - title = resources.getString(R.string.lightning__close_error), - description = resources.getString(R.string.lightning__close_error_msg), - ) - onTransferUnavailable() - }, ) } } @@ -130,7 +122,6 @@ private suspend fun runChannelClose( onSuccess: () -> Unit, onInterrupted: () -> Unit, onGiveUp: () -> Unit, - onUnavailable: () -> Unit, ) { val channelsFailedToCoopClose = transfer.closeSelectedChannels() @@ -141,21 +132,12 @@ private suspend fun runChannelClose( return } - // Check if any channels can be retried (filter out trusted peers) - val (_, nonTrustedChannels) = transfer.separateTrustedChannels(channelsFailedToCoopClose) - - if (nonTrustedChannels.isEmpty()) { - // All channels are trusted peers - show error and navigate back immediately - onUnavailable() - } else { - transfer.startCoopCloseRetries( - channels = nonTrustedChannels, - onGiveUp = onGiveUp, - onTransferUnavailable = onUnavailable, - ) - delay(2500) - onInterrupted() - } + transfer.startCoopCloseRetries( + channels = channelsFailedToCoopClose, + onGiveUp = onGiveUp, + ) + delay(2500) + onInterrupted() } @Composable diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt index 5f6a13d4b0..f6699d04d4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt @@ -22,9 +22,10 @@ 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 org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R -import to.bitkit.env.Peers import to.bitkit.ext.host +import to.bitkit.ext.of import to.bitkit.ext.port import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM @@ -173,7 +174,7 @@ private fun InfoRow( private fun Preview() { AppThemeSurface { Content( - uiState = LnurlChannelUiState(peer = Peers.stag), + uiState = LnurlChannelUiState(peer = previewPeer), ) } } @@ -187,3 +188,7 @@ private fun PreviewLoading() { ) } } + +private val previewPeer = PeerDetails.of( + "0248a2b7c2d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9@127.0.0.1:9735" +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index f9aa4c5da8..317b8d5370 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -58,7 +58,6 @@ import to.bitkit.ui.utils.withAccent @Composable fun SavingsWalletScreen( - isGeoBlocked: Boolean, onchainActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, onEmptyActivityRowClick: () -> Unit, @@ -74,8 +73,7 @@ fun SavingsWalletScreen( mutableStateOf(!hasFunds && !hasActivity) } val canTransfer by remember(balances.totalOnchainSats) { - val hasFunds = balances.totalOnchainSats > 0uL - mutableStateOf(hasFunds && !isGeoBlocked) + mutableStateOf(balances.totalOnchainSats > 0uL) } val hazeState = rememberHazeState() @@ -198,7 +196,6 @@ private fun Preview() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -218,7 +215,6 @@ private fun PreviewTransfer() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -241,7 +237,6 @@ private fun PreviewNoActivity() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, onActivityItemClick = {}, @@ -255,33 +250,12 @@ private fun PreviewNoActivity() { } } -@Preview(showSystemUi = true) -@Composable -private fun PreviewGeoBlocked() { - AppThemeSurface { - Box { - SavingsWalletScreen( - isGeoBlocked = true, - onchainActivities = previewOnchainActivityItems(), - onAllActivityButtonClick = {}, - onActivityItemClick = {}, - onEmptyActivityRowClick = {}, - onTransferToSpendingClick = {}, - onBackClick = {}, - balances = BalanceState(totalOnchainSats = 50_000u), - ) - TabBar() - } - } -} - @Preview(showSystemUi = true) @Composable private fun PreviewEmpty() { AppThemeSurface { Box { SavingsWalletScreen( - isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, onActivityItemClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt index 5b00aa4f5a..f2a15691f5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt @@ -1,29 +1,14 @@ package to.bitkit.ui.screens.wallets.receive -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import to.bitkit.R -import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.sheets.TransferUnavailableContent import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors @Composable fun LocationBlockScreen( @@ -31,40 +16,13 @@ fun LocationBlockScreen( navigateAdvancedSetup: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() - ) { - SheetTopBar(stringResource(R.string.wallet__receive_bitcoin), onBack = onBackPressed) - - Spacer(modifier = Modifier.height(16.dp)) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .weight(1f), - ) { - BodyM(text = stringResource(R.string.lightning__funding__text_blocked_cjit), color = Colors.White64) - - Spacer(modifier = Modifier.weight(1f)) - - Image( - painter = painterResource(R.drawable.globe), - contentScale = ContentScale.FillWidth, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 60.dp) - ) - - Spacer(modifier = Modifier.weight(1f)) - - PrimaryButton(text = stringResource(R.string.onboarding__advanced_setup), onClick = navigateAdvancedSetup) - Spacer(modifier = Modifier.height(16.dp)) - } - } + TransferUnavailableContent( + navTitle = stringResource(R.string.lightning__transfer_unavailable__nav_title), + description = stringResource(R.string.lightning__transfer_unavailable__text), + onContinue = navigateAdvancedSetup, + onBack = onBackPressed, + modifier = modifier, + ) } @Preview(showSystemUi = true) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 418e3b706f..c32f08e225 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -126,6 +126,7 @@ fun SendPendingScreen( AbandonExpiredPayment( abandonError = uiState.abandonErrorMessage(), isAbandoned = uiState.abandoned, + isTransfer = uiState.abandonableAttempt?.isBlocktankFunding == true, showConfirmation = showAbandonConfirmation, onConfirm = { requiresPin, useBiometrics -> showAbandonConfirmation = false @@ -173,6 +174,7 @@ fun SendPendingScreen( private fun AbandonExpiredPayment( abandonError: String?, isAbandoned: Boolean, + isTransfer: Boolean, showConfirmation: Boolean, onConfirm: (requiresPin: Boolean, useBiometrics: Boolean) -> Unit, onDismiss: () -> Unit, @@ -197,7 +199,13 @@ private fun AbandonExpiredPayment( if (showConfirmation) { AppAlertDialog( title = stringResource(R.string.wallet__send_pending__abandon), - text = stringResource(R.string.wallet__send_pending__abandon_note), + text = stringResource( + if (isTransfer) { + R.string.wallet__send_pending__abandon_transfer_note + } else { + R.string.wallet__send_pending__abandon_note + } + ), confirmText = stringResource(R.string.common__continue), dismissText = stringResource(R.string.common__cancel), onConfirm = { onConfirm(isPinEnabled && pinForPayments, isBiometricEnabled && isBiometrySupported) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index 843662ad68..719926fadd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -181,6 +181,13 @@ class SendPendingViewModel @Inject constructor( private fun offerAbandonIfRequestEnded(attempt: OnchainSendAttempt) { if (!attempt.canBeAbandoned) return viewModelScope.launch { + // Transfers do not come back once unavailable, so one check decides a Blocktank funding. + if (attempt.isBlocktankFunding) { + val hasEnded = runSuspendCatching { paykitPaymentProofRepo.onchainRequestHasEnded(attempt) } + .getOrNull() == true + if (hasEnded) _uiState.update { it.copy(abandonableAttempt = attempt) } + return@launch + } var current = attempt while (_uiState.value.recoveryAttempt?.attemptId == attempt.attemptId && _uiState.value.abandonableAttempt == null diff --git a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt index db90c67454..67fd099008 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backgroundPayments/BackgroundPaymentsSettings.kt @@ -142,8 +142,8 @@ private fun Content( NotificationPreview( enabled = hasPermission, - title = stringResource(R.string.notification__received__title), - description = "₿ 21 000 ($21.00)", + title = stringResource(R.string.subscriptions__payment_due_title), + description = stringResource(R.string.subscriptions__payment_due_description), modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt new file mode 100644 index 0000000000..761f478988 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/TransferUnavailableSheet.kt @@ -0,0 +1,70 @@ +package to.bitkit.ui.sheets + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.net.toUri +import to.bitkit.R +import to.bitkit.env.Env +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.SheetIntro +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** Shown instead of the Blocktank transfer to spending, which is no longer available. */ +@Composable +fun TransferUnavailableSheet( + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + TransferUnavailableContent( + navTitle = stringResource(R.string.lightning__transfer_unavailable__nav_title), + description = stringResource(R.string.lightning__transfer_unavailable__text), + onContinue = onContinue, + modifier = modifier.sheetHeight(isModal = true), + ) +} + +@Composable +fun TransferUnavailableContent( + navTitle: String, + description: String, + onContinue: () -> Unit, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + val context = LocalContext.current + SheetIntro( + navTitle = navTitle, + title = stringResource(R.string.lightning__transfer_unavailable__title).withAccent(accentColor = Colors.Purple), + description = AnnotatedString(description), + image = R.drawable.lightning, + continueText = stringResource(R.string.common__continue), + onContinue = onContinue, + cancelText = stringResource(R.string.common__learn_more), + onCancel = { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Env.BLOCKTANK_SUNSET_URL.toUri())) } + }, + onBack = onBack, + testTag = "TransferUnavailable", + cancelTestTag = "TransferUnavailableLearnMore", + continueTestTag = "TransferUnavailableContinue", + modifier = modifier, + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + TransferUnavailableSheet(onContinue = {}) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt index fe43ccd1f5..53080db8ac 100644 --- a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt +++ b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt @@ -13,10 +13,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.ui.areNotificationsEnabled +import to.bitkit.viewmodels.SettingsViewModel @Composable fun RequestNotificationPermissions( @@ -120,3 +123,22 @@ fun rememberNotificationToggleClick( } } } + +/** Asks for notification permission when a Paykit subscription starts, unless it is already granted. */ +@Composable +fun rememberSubscriptionNotificationsRequest( + settingsViewModel: SettingsViewModel = hiltViewModel(), +): () -> Unit { + val isGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() + val requestPermission = rememberRequestNotificationPermission( + onPermissionResult = settingsViewModel::setNotificationPreference, + onPreTiramisu = {}, + ) + val currentIsGranted by rememberUpdatedState(isGranted) + + return remember(requestPermission) { + { + if (!currentIsGranted) requestPermission() + } + } +} diff --git a/app/src/main/java/to/bitkit/utils/LoggerLdk.kt b/app/src/main/java/to/bitkit/utils/LoggerLdk.kt index ecba3cfb79..7c8fc8d52a 100644 --- a/app/src/main/java/to/bitkit/utils/LoggerLdk.kt +++ b/app/src/main/java/to/bitkit/utils/LoggerLdk.kt @@ -6,7 +6,6 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.LogRecord import org.lightningdevkit.ldknode.LogWriter import org.lightningdevkit.ldknode.Node -import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import java.io.File @@ -24,7 +23,7 @@ class LoggerLdk @Inject constructor( private const val TAG = "LogDumperLdk" } - fun dumpNetworkGraphInfo(node: Node, trustedPeers: List, bolt11: String) { + fun dumpNetworkGraphInfo(node: Node, bolt11: String) { val nodeIdPreviewLen = 20 val sb = StringBuilder() @@ -137,19 +136,6 @@ class LoggerLdk @Inject constructor( } } - sb.appendLine("\n Checking for trusted peers in network graph:") - var foundTrustedNodes = 0 - trustedPeers.forEach { peer -> - val nodeId = peer.nodeId - if (allNodes.any { it == nodeId }) { - foundTrustedNodes++ - sb.appendLine(" OK: ${nodeId.take(nodeIdPreviewLen)}... found in graph") - } else { - sb.appendLine(" MISSING: ${nodeId.take(nodeIdPreviewLen)}... NOT in graph") - } - } - sb.appendLine(" Summary: $foundTrustedNodes/${trustedPeers.size} trusted peers found in graph") - val nodesToShow = minOf(10, allNodes.size) sb.appendLine("\n First $nodesToShow nodes:") allNodes.take(nodesToShow).forEachIndexed { index, nodeId -> diff --git a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt index 068dcf5561..dbba61a0e6 100644 --- a/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt +++ b/app/src/main/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheet.kt @@ -3,7 +3,7 @@ package to.bitkit.utils.timedsheets.sheets import kotlinx.coroutines.flow.first import to.bitkit.data.SettingsStore import to.bitkit.ext.nowMillis -import to.bitkit.repositories.WalletRepo +import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.ui.components.TimedSheetType import to.bitkit.utils.Logger import to.bitkit.utils.timedsheets.ONE_WEEK_ASK_INTERVAL_MILLIS @@ -14,7 +14,7 @@ import kotlin.time.ExperimentalTime class NotificationsTimedSheet @Inject constructor( private val settingsStore: SettingsStore, - private val walletRepo: WalletRepo, + private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, ) : TimedSheetItem { override val type = TimedSheetType.NOTIFICATIONS override val priority = 3 @@ -22,7 +22,7 @@ class NotificationsTimedSheet @Inject constructor( override suspend fun shouldShow(): Boolean { val settings = settingsStore.data.first() if (settings.notificationsGranted) return false - if (walletRepo.balanceState.value.totalLightningSats == 0UL) return false + if (paykitPaymentRequestRepo.subscriptions.value.isEmpty()) return false return checkTimeout( lastIgnoredMillis = settings.notificationsIgnoredMillis, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 999e5d46fd..33f69b5e4a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -6048,6 +6048,14 @@ class AppViewModel @Inject constructor( feeRateSatsPerVByte: ULong, approvePrepared: suspend (OnchainPreparedReceipt, List) -> Unit = { _, _ -> }, ): Result { + // Blocktank no longer sells channels, so an unpaid order is never paid again. + if (original.orderId != null && lightningRepo.isTransferBlocked()) { + Logger.warn("Stopped retrying a transfer to spending: transfers are unavailable", context = TAG) + showSheet(Sheet.TransferUnavailable) + return Result.failure( + IllegalStateException(context.getString(R.string.lightning__transfer_unavailable__nav_title)) + ) + } val request = original.requestId?.let { id -> (paykitPaymentRequestRepo.pendingRequests.value + paykitPaymentRequestRepo.paymentRequestHistory.value) .firstOrNull { it.id == id } diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index 65d6c1e48f..b0ef143132 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -4,22 +4,18 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview -import com.synonym.bitkitcore.testNotification import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.WidgetsStore -import to.bitkit.env.Env import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -48,7 +44,6 @@ data class LegacyRnRecoveryUiState( @HiltViewModel class DevSettingsViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val firebaseMessaging: FirebaseMessaging, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val widgetsStore: WidgetsStore, @@ -166,30 +161,6 @@ class DevSettingsViewModel @Inject constructor( .onFailure { ToastEventBus.send(it) } } - fun registerForNotifications() = viewModelScope.launch { - lightningRepo.registerForNotifications() - .onSuccess { - @Suppress("KotlinConstantConditions") - val title = if (Env.isE2eTest) "Skipped push registration" else "Registered for notifications" - ToastEventBus.send(type = Toast.ToastType.INFO, title = title) - } - .onFailure { ToastEventBus.send(it) } - } - - fun testLspNotification() = viewModelScope.launch { - runCatching { - testNotification( - deviceToken = firebaseMessaging.token.await(), - secretMessage = "hello", - notificationType = "incomingHtlc", - customUrl = Env.blocktankNotificationApiUrl, - ) - ToastEventBus.send(type = Toast.ToastType.INFO, title = "LSP notification sent to this device") - }.onFailure { - ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Error testing LSP notification") - } - } - fun fakeBgReceive() = viewModelScope.launch { cacheStore.setBackgroundReceive( NewTransactionSheetDetails( diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 61db2e66a3..2c7aaab98c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -357,7 +357,9 @@ class TransferViewModel @Inject constructor( private suspend fun getOrCreateSpendingOrder(): IBtOrder? { val state = _spendingUiState.value - return state.order ?: blocktankRepo.createOrder( + state.order?.let { return it } + if (isTransferUnavailable()) return null + return blocktankRepo.createOrder( spendingBalanceSats = state.clientBalanceSat, receivingBalanceSats = state.lspBalanceSat, ).getOrElse { @@ -414,6 +416,7 @@ class TransferViewModel @Inject constructor( return false } if (cacheStore.data.first().paidOrders.containsKey(order.id)) return true + if (isTransferUnavailable()) return false val plan = resolveSpendingConfirmPlan(order, shown) ?: return false Logger.debug( @@ -477,6 +480,13 @@ class TransferViewModel @Inject constructor( ) } + private suspend fun isTransferUnavailable(): Boolean { + if (!lightningRepo.isTransferBlocked()) return false + Logger.warn("Skipped paying spending order: transfers are unavailable", context = TAG) + transferEffects.emit(TransferEffect.OnTransferUnavailable) + return true + } + private suspend fun showRetainedFundingPending(): Boolean { val attempt = lightningRepo.currentOnchainSendAttempt() ?: return false if (!attempt.isTransfer || !attempt.blocksNextSend) return false @@ -1227,6 +1237,7 @@ class TransferViewModel @Inject constructor( val signedOrder = _spendingUiState.value.order val isBroadcastRetry = signedOrder != null && pendingHwFundingBroadcast?.matches(signedOrder, walletId, signedOrder.fundingAddress) == true + if (isTransferUnavailable()) return@launch if (!isBroadcastRetry && hwWalletRepo.needsPassphrase(walletId)) { _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } return@launch @@ -1862,10 +1873,6 @@ class TransferViewModel @Inject constructor( /** Closes the channels selected earlier, pending closure */ suspend fun closeSelectedChannels() = closeChannels(channelsToClose) - fun separateTrustedChannels( - channels: List, - ): Pair, List> = lightningRepo.separateTrustedChannels(channels) - private suspend fun closeChannels(channels: List): List { lightningRepo.awaitPeerConnected() val channelsFailedToClose = coroutineScope { @@ -1897,7 +1904,6 @@ class TransferViewModel @Inject constructor( fun startCoopCloseRetries( channels: List, onGiveUp: () -> Unit, - onTransferUnavailable: () -> Unit, ) { val startTimeMs = clock.now().toEpochMilliseconds() channelsToClose = channels @@ -1921,59 +1927,20 @@ class TransferViewModel @Inject constructor( delay(RETRY_INTERVAL_MS) } - Logger.info("Giving up on coop close. Checking if force close is possible.", context = TAG) - - // Check if any channels can be force closed (filter out trusted peers) - val (_, nonTrustedChannels) = lightningRepo.separateTrustedChannels(channelsToClose) - - if (nonTrustedChannels.isNotEmpty()) { - onGiveUp() - } else { - Logger.warn("All channels are with trusted peers. Cannot force close.", context = TAG) - channelsToClose = emptyList() - onTransferUnavailable() - } + Logger.info("Giving up on coop close.", context = TAG) + onGiveUp() } } fun forceTransfer(onComplete: () -> Unit) = viewModelScope.launch { _isForceTransferLoading.value = true runCatching { - // Filter out trusted peer channels (cannot force close LSP channels) - val (trustedChannels, nonTrustedChannels) = lightningRepo.separateTrustedChannels(channelsToClose) - - if (trustedChannels.isNotEmpty()) { - Logger.warn("Skipping ${trustedChannels.size} trusted peer channel(s)", context = TAG) - } - - if (nonTrustedChannels.isEmpty()) { - channelsToClose = emptyList() - Logger.error("Cannot force close channels with trusted peer", context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__force_failed_title), - description = context.getString(R.string.lightning__force_failed_msg) - ) - return@runCatching - } - - val failedChannels = forceCloseChannels(nonTrustedChannels) - - // Remove successfully closed channels and trusted peer channels from the list - val successfulChannelIds = nonTrustedChannels - .filterNot { channel -> failedChannels.any { it.channelId == channel.channelId } } - .map { it.channelId } - .toSet() - val trustedChannelIds = trustedChannels.map { it.channelId }.toSet() - channelsToClose = channelsToClose.filterNot { - it.channelId in successfulChannelIds || it.channelId in trustedChannelIds - } + val failedChannels = forceCloseChannels(channelsToClose) + channelsToClose = failedChannels if (failedChannels.isEmpty()) { Logger.info("Force close initiated successfully for all channels", context = TAG) - val initMsg = context.getString(R.string.lightning__force_init_msg) - val skippedMsg = context.getString(R.string.lightning__force_channels_skipped) - val description = if (trustedChannels.isNotEmpty()) "$initMsg $skippedMsg" else initMsg + val description = context.getString(R.string.lightning__force_init_msg) ToastEventBus.send( type = Toast.ToastType.LIGHTNING, title = context.getString(R.string.lightning__force_init_title), @@ -2170,6 +2137,7 @@ sealed interface TransferEffect { data object OnSpendingFundingPaid : TransferEffect data class OnFundingPending(val attempt: to.bitkit.repositories.OnchainSendAttempt) : TransferEffect data object OnHwTxSigned : TransferEffect + data object OnTransferUnavailable : TransferEffect data class ToastException(val e: Throwable) : TransferEffect data class ToastError(val title: String, val description: String) : TransferEffect } diff --git a/app/src/main/res/drawable/ic_device_tablet_speaker.xml b/app/src/main/res/drawable/ic_device_tablet_speaker.xml new file mode 100644 index 0000000000..4ac0ee6adb --- /dev/null +++ b/app/src/main/res/drawable/ic_device_tablet_speaker.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 347f4831c6..240b4926fc 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -9,8 +9,8 @@ دعوة مدفوعات فورية إنفاق - عندما يكون Bitkit مغلقًا - استلم أموالك + تذكيرات الدفع + الإشعارات إعداد رمز PIN تأمين امسح وادفع @@ -150,7 +150,6 @@ وقت تحديث ذاكرة التخزين المؤقت لمعدل الرسوم الرسوم تحويل إجباري - تعذر إغلاق بعض الاتصالات. تعذر تحويل أموالك إلى المدخرات. يرجى المحاولة مرة أخرى. فشل التحويل الإجباري ستكون أموالك متاحة خلال ±14 يومًا. @@ -162,8 +161,6 @@ استخدم محفظة أخرى رصيد الإنفاق أسهل خيار هو استخدام مدخرات Bitkit الخاصة بك أو إرسال بيتكوين من محفظة مختلفة. - لا يوفر Bitkit حاليًا خدمات Lightning في بلدك، لكن يمكنك الاتصال بعقد أخرى مباشرة. - لا يوفر Bitkit حاليًا خدمات Lightning في بلدك، لكن يمكنك الاتصال بعقد أخرى مباشرة. موّل <accent>رصيد الإنفاق</accent> رمز QR لقناة LNURL إعداد يدوي @@ -273,22 +270,11 @@ يرجى الانتظار أثناء ترحيل بيانات محفظتك القديمة إلى إصدار Bitkit الجديد. عادةً ما يستغرق هذا أقل من دقيقة. ترحيل المحفظة جارٍ ترحيل\n<accent>المحفظة</accent> - تم نقل الرصيد من الإنفاق إلى المدخرات - السبب: %s - تم إغلاق القناة قناة الإشعارات لخدمة عقدة Lightning في المقدمة إشعار عقدة Lightning - فشل فتح القناة في الخلفية - فشل فتح القناة - تم فتح القناة - معلق - جاهز للإرسال - خطأ Lightning - فشل الدفع - يرجى المحاولة مرة أخرى تم استلام %s تم استلام الدفع - Bitkit يعمل في الخلفية حتى تتمكن من استلام مدفوعات Lightning + Bitkit يعمل في الخلفية حتى تتمكن من استلام المدفوعات والطلبات إيقاف التطبيق متقدم إعداد متقدم @@ -591,16 +577,16 @@ نسخ محفظتك احتياطيًا نسخ البيانات الاحتياطية تخصيص في إعدادات Bitkit على Android - المدفوعات في الخلفية معطلة، لأنك رفضت الإشعارات. - المدفوعات في الخلفية مفعلة. يمكنك استلام الأموال حتى عندما يكون التطبيق مغلقًا (إذا كان جهازك متصلاً بالإنترنت). - فعّل الإشعارات لاستلام الأموال، حتى عندما يكون تطبيق Bitkit مغلقًا. - استلم الأموال\n<accent>بشكل سلبي</accent> + الإشعارات متوقفة لأنك رفضتها. + الإشعارات مفعّلة. يمكن لـ Bitkit تذكيرك بالاشتراكات وطلبات الدفع. + فعّل الإشعارات لتصلك تذكيرات باشتراكاتك وطلبات الدفع. + ابقَ\n<accent>على اطلاع</accent> الإشعارات إيقاف تشغيل الإعداد في الخلفية - استلم الأموال عندما يكون Bitkit مغلقًا - المدفوعات في الخلفية + السماح بالإشعارات + الإشعارات يجد تطابقات دقيقة للمبلغ لتقليل الصرف Branch and Bound اختيار عشوائي للخصوصية @@ -1264,6 +1250,7 @@ لم يتم العثور على القناة تم تحويل الأموال في رصيد إنفاقك إلى مدخراتك. تم إغلاق الاتصال + تحويل من محفظة الأجهزة إلى المدخرات المبلغ منخفض جدًا للتحويل إلى المدخرات. إغلاق القناة بدلاً من ذلك @@ -1288,6 +1275,9 @@ وقّع باستخدام\n<accent>جهازك</accent> تم توقيع\n<accent>المعاملة</accent> التحويل جاهز خلال %s + التحويل غير متاح + لم تعد التحويلات التلقائية إلى رصيد الإنفاق متاحة. استخدم عقدتك الخاصة، أو أنشئ اتصالًا مع مزوّد آخر. + <accent>الإعداد اليدوي</accent>\nمطلوب يرجى التأكد من أن لديك اتصالاً مستقرًا بالإنترنت. قد تظهر البيانات بشكل غير صحيح أثناء محاولة الاتصال. تم اكتشاف مشاكل في الشبكة لا يحتوي رمز QR هذا على SeedQR صالح من 12 كلمة. @@ -1551,7 +1541,7 @@ اسم الاشتراك مستمر نظرة عامة - افتح 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 1909cb5311..967cda1ff1 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -9,8 +9,8 @@ Invitar Pagos instantáneos Gastar - Sin Bitkit abierto - Cobrar + Recordatorios de pago + Notificaciones Configurar un código PIN PROTEGER Escanear y pagar @@ -157,7 +157,6 @@ Hora de actualización de caché de tarifa Tarifa Fuerzar transferencia - Algunas conexiones no pudieron cerrarse. No se pudo transferir tus fondos a ahorros. Intenta de nuevo. Transferencia forzada falló Sus fondos estarán accesibles en ±14 días. @@ -169,8 +168,6 @@ Usar Otra Billetera Saldo de gastos La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde una billetera diferente. - Bitkit no tiene soporte Lightning en tu región, pero puedes usar nodos externos manualmente. - Bitkit no tiene soporte Lightning en tu región, pero puedes usar nodos externos manualmente. Fondee su <accent>saldo de gastos</accent> QR de canal LNURL Configuración Manual @@ -281,22 +278,11 @@ Por favor espera mientras se migran los datos de tu billetera anterior a esta nueva versión de Bitkit. Esto normalmente tarda menos de un minuto. Migración de billetera MIGRANDO\n<accent>BILLETERA</accent> - Saldo movido de gastos a ahorros - Razón: %s - Canal cerrado Canal de notificación para el servicio en segundo plano del nodo Lightning Notificación del nodo Lightning - El canal no se pudo abrir en segundo plano - Apertura del canal fallida - Canal abierto - Pendiente - Listo para enviar - Error de Lightning - Pago fallido - Por favor, inténtalo de nuevo Recibido %s Pago recibido - Bitkit está ejecutándose en segundo plano para que puedas recibir pagos Lightning + Bitkit está ejecutándose en segundo plano para que puedas recibir pagos y solicitudes Detener app Avanzado Configuración avanzada @@ -613,16 +599,16 @@ Haga una copia de seguridad de su cartera Copias de seguridad Personalizar en Ajustes de Bitkit en Android - Los pagos en segundo plano están desactivados porque has denegado las notificaciones. - Los pagos en segundo plano están activados. Puedes recibir fondos incluso cuando la app está cerrada (si tu dispositivo está conectado a internet). - Activa las notificaciones para recibir pagos, incluso cuando tu app Bitkit está cerrada. - RECIBE PAGOS\n<accent>PASIVAMENTE</accent> + Las notificaciones están desactivadas porque las rechazaste. + Las notificaciones están activadas. Bitkit puede recordarte tus suscripciones y solicitudes de pago. + Activa las notificaciones para recibir recordatorios de tus suscripciones y solicitudes de pago. + Mantente\n<accent>informado</accent> Notificaciones Apagado Encendido Configurar en segundo plano - Recibir pagos cuando Bitkit está cerrado - Pagos en segundo plano + Permitir notificaciones + Notificaciones Encuentra coincidencias exactas de monto para minimizar el cambio Branch and Bound Selección aleatoria para mayor privacidad @@ -1295,6 +1281,7 @@ Falló la verificación de la dirección. Revise la dirección en su dispositivo y vuelva a intentarlo. Este dispositivo contiene una billetera distinta a la que vinculó. Conecte la billetera vinculada o añada esta como una nueva billetera de hardware. Canal no encontrado + Transferir desde billetera HW A ahorros El monto es demasiado bajo para transferirlo a ahorros. Cerrar el canal en su lugar @@ -1318,6 +1305,9 @@ Firme con\n<accent>su dispositivo</accent> Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s + Transferencia no disponible + Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos podrían mostrarse incorrectamente mientras se intenta conectar. Problemas de red detectados Este código QR no contiene un SeedQR válido de 12 palabras. @@ -1566,7 +1556,7 @@ Nombre de la suscripción En curso Resumen - Abra Bitkit para revisar un pago de suscripción. + Abra Bitkit para revisar el pago Pago de suscripción pendiente Métodos de pago Por día diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index f80d8e1ccc..e06dddfcc2 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -9,8 +9,8 @@ Convidar Pagaments instantanis Gastar - Quan Bitkit està tancat - Rep pagaments + Recordatoris de pagament + Notificacions Configurar un codi PIN Segur Escaneja i paga @@ -154,7 +154,6 @@ Temps d\'actualització de la memòria cau de tarifes Tarifes Forçar transferència - Algunes connexions no s\'han pogut tancar. No es possible transferir els teus fons de tornada a estalvis. Si us plau, intenta-ho de nou. Transferència forçada fallida Els teus fons seran accessibles en ±14 dies. @@ -166,8 +165,6 @@ Utilitza un altre moneder Balanç de despesa La forma més fàcil és utilitzar els teus estalvis de Bitkit o enviar bitcoin des d\'una altra cartera. - Bitkit no ofereix serveis Lightning al teu país, però encara pots connectar-te a altres nodes directament. - Bitkit no ofereix serveis Lightning al teu país, però encara pots connectar-te a altres nodes directament. Finança el teu <accent>saldo de despesa</accent> QR de canal LNURL Configuració manual @@ -277,22 +274,11 @@ Si us plau, espera mentre les dades de la teva antiga cartera es migren a aquesta nova versió de Bitkit. Normalment triga menys d\'un minut. Migració de cartera MIGRANT\n<accent>CARTERA</accent> - Saldo mogut de despesa a estalvis - Motiu: %s - Canal tancat Canal de notificació per al servei en primer pla del node Lightning Notificació del node Lightning - El canal no s\'ha pogut obrir en segon pla - Obertura de canal fallida - Canal obert - Pendent - Llest per enviar - Error de Lightning - Pagament fallit - Si us plau, torna-ho a provar Rebut %s Pagament rebut - Bitkit s\'executa en segon pla perquè puguis rebre pagaments Lightning + Bitkit s\'executa en segon pla perquè puguis rebre pagaments i sol·licituds Aturar aplicació Avançat Configuració avançada @@ -606,16 +592,16 @@ Fes còpia de seguretat de la cartera Còpies de seguretat Personalitza a la configuració de Bitkit d\'Android - Els pagaments en segon pla estan desactivats perquè has denegat les notificacions. - Els pagaments en segon pla estan habilitats. Pots rebre fons fins i tot quan l\'aplicació està tancada (si el teu dispositiu està connectat a Internet). - Activa les notificacions per rebre pagaments, fins i tot quan la teva aplicació Bitkit està tancada. - REP PAGAMENTS\n<accent>PASSIVAMENT</accent> + Les notificacions estan desactivades perquè les has rebutjat. + Les notificacions estan activades. Bitkit et pot recordar les subscripcions i sol·licituds de pagament. + Activa les notificacions per rebre recordatoris de les teves subscripcions i sol·licituds de pagament. + Mantén-te\n<accent>informat</accent> Notificacions Desactivat Activat Configurar en segon pla - Rep pagaments quan Bitkit està tancat - Pagaments en segon pla + Permet les notificacions + Notificacions Troba coincidències d\'import exactes per minimitzar el canvi Branch and Bound Selecció aleatòria per a privacitat @@ -1288,6 +1274,7 @@ Aquest dispositiu conté una cartera diferent de la que has vinculat. Connecta la cartera vinculada o afegeix aquesta com a nova cartera de maquinari. Canal no trobat Els fons del teu saldo de despesa s\'han transferit als teus estalvis. + Transferir des del moneder HW A estalvis L\'import és massa baix per transferir-lo a estalvis. Tancar el canal @@ -1312,6 +1299,9 @@ Signa amb\n<accent>el teu dispositiu</accent> Transacció\n<accent>signada</accent> TRANSFERÈNCIA LLESTA EN %s + Transferència no disponible + Les transferències automàtiques al teu saldo de despesa ja no estan disponibles. Fes servir el teu propi node o configura una connexió amb un altre proveïdor. + <accent>Configuració manual</accent>\nnecessària Assegura\'t que tens una connexió a Internet estable. És possible que les dades es mostrin incorrectament mentre s\'intenta connectar. S\'han detectat problemes de xarxa Aquest codi QR no conté un SeedQR de 12 paraules vàlid. @@ -1563,7 +1553,7 @@ Nom de la subscripció En curs Resum - Obre Bitkit per revisar un pagament de subscripció. + Obre Bitkit per revisar el pagament Pagament de subscripció pendent Mètodes de pagament Per dia diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 320928f091..480fb7af5a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -9,8 +9,8 @@ Pozvat Okamžité platby Utratit - Když je Bitkit zavřený - Přijímat platby + Připomínky plateb + Oznámení Nastavte PIN kód Zabezpečit Naskenuj a zaplať @@ -157,7 +157,6 @@ Čas aktualizace mezipaměti poplatků Poplatky Vynutit převod - Některá spojení se nepodařilo uzavřít. Nelze převést vaše prostředky zpět do úspor. Zkuste to prosím znovu. Vynucení převodu selhalo. Vaše prostředky budou dostupné do ±14 dnů. @@ -169,8 +168,6 @@ Použijte jinou peněženku Dostupný zůstatek Nejjednodušší možností je použít úspory z Bitkitu nebo poslat bitcoin z jiné peněženky. - Bitkit v současné době neposkytuje služby lightning ve vaší zemi, ale stále se můžete připojit k jiným uzlům přímo. - Bitkit v současné době neposkytuje služby lightning ve vaší zemi, ale stále se můžete připojit k jiným uzlům přímo. Navyšte svůj <accent>disponibilní zůstatek</accent> LNURL kanál QR Manuální nastavení @@ -280,22 +277,11 @@ Počkejte prosím, než budou vaše stará data peněženky migrována do této nové verze Bitkit. Obvykle to trvá méně než minutu. Migrace peněženky MIGRACE\nPENĚŽENKY - Zůstatek byl přesunut z útrat do úspor - Důvod: %s - Kanál uzavřen Notifikační kanál pro službu Lightning uzlu na pozadí Notifikace Lightning uzlu - Kanál se nepodařilo otevřít na pozadí - Otevření kanálu selhalo - Kanál otevřen - Čekající - Připraveno k odeslání - Chyba Lightning - Platba selhala - Zkuste to prosím znovu Přijato %s Platba přijata - Bitkit běží na pozadí, abyste mohli přijímat Lightning platby + Bitkit běží na pozadí, abyste mohli přijímat platby a žádosti Zastavit aplikaci Pokročilé Pokročilé nastavení @@ -611,16 +597,16 @@ Zálohujte svou peněženku Zálohy dat Přizpůsobit v nastavení Android Bitkit - Platby na pozadí jsou zakázány, protože jste odmítli oznámení. - Platby na pozadí jsou povoleny. Prostředky můžete přijímat i při zavřené aplikaci (pokud je zařízení připojeno k internetu). - Zapněte oznámení, abyste dostávali platby i když je aplikace Bitkit zavřená. - DOSTÁVAT PLATBY\nPASIVNĚ + Oznámení jsou vypnutá, protože jste je zamítli. + Oznámení jsou zapnutá. Bitkit vám může připomínat předplatná a žádosti o platbu. + Zapněte oznámení a dostávejte připomínky předplatných a žádostí o platbu. + Buďte\n<accent>v obraze</accent> Oznámení Vypnuto Zapnuto Nastavit na pozadí - Dostávat platby když je Bitkit zavřený - Platby na pozadí + Povolit oznámení + Oznámení Najde přesné shody částek pro minimalizaci zbytku Branch and Bound Náhodný výběr pro soukromí @@ -1292,6 +1278,7 @@ Ověření adresy se nezdařilo. Zkontrolujte adresu na zařízení a zkuste to znovu. Toto zařízení obsahuje jinou peněženku než tu, kterou jste spárovali. Připojte spárovanou peněženku, nebo tuto přidejte jako novou hardwarovou peněženku. Kanál nenalezen + Převod z HW peněženky Do úspor Částka je příliš nízká pro převod do úspor. Místo toho uzavřít kanál @@ -1316,6 +1303,9 @@ Podepište\n<accent>svým zařízením</accent> Transakce\n<accent>podepsána</accent> PŘEVOD PŘIPRAVEN ZA %s + Převod není dostupný + Automatické převody na zůstatek útrat již nejsou k dispozici. Použijte vlastní uzel nebo si nastavte připojení u jiného poskytovatele. + <accent>Vyžadováno ruční</accent>\nnastavení Zajistěte prosím stabilní připojení k internetu. Během pokusu o připojení se data mohou zobrazovat nesprávně. Zjištěny problémy se sítí Tento QR kód neobsahuje platný 12slovný SeedQR. @@ -1565,7 +1555,7 @@ Název předplatného Průběžné Přehled - Otevřete Bitkit a zkontrolujte platbu předplatného. + Otevřete Bitkit a zkontrolujte platbu Splatná platba předplatného Platební metody Za den diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1f18e1a92f..f54e62746c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,8 +19,8 @@ Hilfe bekommen Kaufen Kaufe bitcoin - Wenn Bitkit geschlossen ist - Empfangen + Zahlungserinnerungen + Benachrichtigungen Wallet-Übersicht Diese Funktion wird derzeit entwickelt und ist bald verfügbar. Bald\n<accent>verfügbar</accent> @@ -107,8 +107,6 @@ Spending Balance Fund your <accent>spending balance</accent> Am einfachsten ist es, deine Bitkit-Ersparnisse zu verwenden oder Bitcoin von einer anderen Wallet zu senden. - Bitkit bietet derzeit keine Lightning-Dienste in deinem Land an, aber du kannst dich trotzdem direkt mit anderen Knotenpunkten verbinden. - Bitkit bietet derzeit keine Lightning-Dienste in deinem Land an, aber du kannst dich trotzdem direkt mit anderen Knotenpunkten verbinden. Von Sparkonto übertragen Anderes Wallet verwenden Ausgabenkonto @@ -251,7 +249,6 @@ Forcierte Übertragung initiiert Dein Guthaben wird in ±14 Tagen zur Verfügung stehen. Forcierte Übertragung fehlgeschlagen - Einige Verbindungen konnten nicht geschlossen werden. Dein Guthaben konnte nicht auf dein Sparkonto übertragen werden. Bitte versuche es erneut. Ausgabenguthaben bereit Du kannst jetzt jedermann, überall, instant bezahlen. @@ -280,22 +277,11 @@ Bitte warte, während deine alten Wallet-Daten auf diese neue Bitkit-Version migriert werden. Das dauert normalerweise weniger als eine Minute. Wallet-Migration WALLET\n<accent>MIGRATION</accent> - Guthaben von Ausgaben- auf Sparkonto verschoben - Grund: %s - Channel geschlossen Benachrichtigungskanal für den Lightning-Node-Vordergrunddienst Lightning-Node-Benachrichtigung - Channel konnte im Hintergrund nicht geöffnet werden - Channel-Öffnung fehlgeschlagen - Channel geöffnet - Ausstehend - Bereit zum Senden - Lightning-Fehler - Zahlung fehlgeschlagen - Bitte versuche es erneut %s empfangen Zahlung empfangen - Bitkit läuft im Hintergrund, damit du Lightning-Zahlungen empfangen kannst + Bitkit läuft im Hintergrund, damit du Zahlungen und Anfragen empfangen kannst App stoppen Bitkit\n<accent>Nutzungs-bedingungen</accent> Nutzungsbedingungen @@ -611,13 +597,13 @@ Quickpay-Schwelle * Bitkit QuickPay unterstützt ausschließlich Zahlungen von deinem Guthaben. QuickPay beschleunigt den Bezahlvorgang, indem gescannte QR-Codes automatisch bezahlt werden. - Hintergrund-Zahlungen - PASSIV\n<accent>EMPFANGEN</accent> - Aktiviere Benachrichtigungen, um Zahlungen zu empfangen, auch wenn deine Bitkit-App geschlossen ist. - Hintergrund-Zahlungen sind aktiviert. Du kannst Gelder empfangen, auch wenn die App geschlossen ist (wenn dein Gerät mit dem Internet verbunden ist). - Hintergrund-Zahlungen sind deaktiviert, da du Benachrichtigungen abgelehnt hast. + Benachrichtigungen + Bleib\n<accent>informiert</accent> + Aktiviere Benachrichtigungen, um an deine Abonnements und Zahlungsanfragen erinnert zu werden. + Benachrichtigungen sind aktiviert. Bitkit kann dich an Abonnements und Zahlungsanfragen erinnern. + Benachrichtigungen sind deaktiviert, weil du sie abgelehnt hast. Benachrichtigungen - Empfange Zahlungen wenn Bitkit geschlossen ist + Benachrichtigungen erlauben In Android Bitkit-Einstellungen anpassen An Aus @@ -1289,6 +1275,7 @@ Adressüberprüfung fehlgeschlagen. Überprüfe die Adresse auf deinem Gerät und versuche es erneut. Auf diesem Gerät befindet sich ein anderes Wallet als das gekoppelte. Verbinde das gekoppelte Wallet oder füge dieses als neues Hardware-Wallet hinzu. Kanal nicht gefunden + Von HW-Wallet übertragen Zum Sparkonto Der Betrag ist zu niedrig für eine Übertragung auf das Sparkonto. Stattdessen Kanal schließen @@ -1313,6 +1300,9 @@ Mit deinem\n<accent>Gerät signieren</accent> Transaktion\n<accent>signiert</accent> ÜBERTRAGUNG BEREIT IN %s + Übertragung nicht verfügbar + Automatische Übertragungen auf dein Ausgabenkonto sind nicht mehr verfügbar. Nutze deinen eigenen Node oder richte eine Verbindung mit einem anderen Anbieter ein. + <accent>Manuelle Einrichtung</accent>\nerforderlich Bitte stelle sicher, dass du eine stabile Internetverbindung hast. Während des Verbindungsversuchs werden Daten möglicherweise falsch angezeigt. Netzwerkprobleme erkannt Dieser QR-Code enthält keinen gültigen 12-Wörter-SeedQR. @@ -1564,7 +1554,7 @@ Name des Abonnements Laufend Übersicht - Öffne Bitkit, um eine Abonnementzahlung zu prüfen. + Öffne Bitkit, um die Zahlung zu prüfen Abonnementzahlung fällig Zahlungsmethoden Pro Tag diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 54236b6eff..2946724699 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -9,8 +9,8 @@ Πρόσκληση Άμεσες πληρωμές Δαπάνη - Όταν το Bitkit είναι κλειστό - Λήψη πληρωμών + Υπενθυμίσεις πληρωμών + Ειδοποιήσεις Καταχωρήστε κωδικό PIN Ασφάλεια Σάρωσε και πλήρωσε @@ -150,7 +150,6 @@ Χρόνος ενημέρωσης cache τελών Τέλη Αναγκαστική μεταφορά - Κάποιες συνδέσεις δεν μπόρεσαν να κλείσουν. Δεν ήταν δυνατή η μεταφορά των κεφαλαίων σου στις αποταμιεύσεις. Δοκίμασε ξανά. Αποτυχία αναγκαστικής μεταφοράς Τα κεφάλαιά σου θα είναι διαθέσιμα σε ±14 ημέρες. @@ -162,8 +161,6 @@ Χρήση άλλου πορτοφολιού Υπόλοιπο δαπανών Ο ευκολότερος τρόπος είναι να χρησιμοποιήσεις τις αποταμιεύσεις Bitkit ή να στείλεις bitcoin από διαφορετικό πορτοφόλι. - Το Bitkit δεν παρέχει προς το παρόν υπηρεσίες Lightning στη χώρα σου, αλλά μπορείς να συνδεθείς απευθείας σε άλλους κόμβους. - Το Bitkit δεν παρέχει προς το παρόν υπηρεσίες Lightning στη χώρα σου, αλλά μπορείς να συνδεθείς απευθείας σε άλλους κόμβους. Χρηματοδότησε το <accent>υπόλοιπο δαπανών</accent> LNURL Channel QR Χειροκίνητη ρύθμιση @@ -274,22 +271,11 @@ Περίμενε όσο τα παλιά δεδομένα του πορτοφολιού μεταφέρονται σε αυτή τη νέα έκδοση του Bitkit. Αυτό συνήθως διαρκεί λιγότερο από ένα λεπτό. Μετάβαση πορτοφολιού ΜΕΤΑΒΑΣΗ\n<accent>ΠΟΡΤΟΦΟΛΙΟΥ</accent> - Το υπόλοιπο μετακινήθηκε από δαπάνες σε αποταμιεύσεις - Λόγος: %s - Το κανάλι έκλεισε Κανάλι ειδοποιήσεων για υπηρεσία προσκηνίου κόμβου Lightning Ειδοποίηση κόμβου Lightning - Το κανάλι απέτυχε να ανοίξει στο παρασκήνιο - Αποτυχία ανοίγματος καναλιού - Το κανάλι άνοιξε - Εκκρεμεί - Έτοιμο για αποστολή - Σφάλμα Lightning - Η πληρωμή απέτυχε - Δοκίμασε ξανά Ελήφθησαν %s Ελήφθη πληρωμή - Το Bitkit εκτελείται στο παρασκήνιο για να μπορείς να λαμβάνεις πληρωμές Lightning + Το Bitkit εκτελείται στο παρασκήνιο για να μπορείς να λαμβάνεις πληρωμές και αιτήματα Διακοπή εφαρμογής Για προχωρημένους Ρύθμιση για προχωρημένους @@ -592,16 +578,16 @@ Αντίγραφο ασφαλείας πορτοφολιού Αντίγραφα δεδομένων Προσαρμογή στις ρυθμίσεις Android Bitkit - Οι πληρωμές παρασκηνίου είναι απενεργοποιημένες επειδή αρνήθηκες τις ειδοποιήσεις. - Οι πληρωμές παρασκηνίου είναι ενεργοποιημένες. Μπορείς να λαμβάνεις κεφάλαια ακόμα και όταν η εφαρμογή είναι κλειστή (αν η συσκευή σου είναι συνδεδεμένη στο διαδίκτυο). - Ενεργοποίησε τις ειδοποιήσεις για να λαμβάνεις πληρωμές ακόμα και όταν η εφαρμογή Bitkit είναι κλειστή. - ΛΑΒΕ ΠΛΗΡΩΜΕΣ\n<accent>ΠΑΘΗΤΙΚΑ</accent> + Οι ειδοποιήσεις είναι απενεργοποιημένες, επειδή τις απορρίψατε. + Οι ειδοποιήσεις είναι ενεργές. Το Bitkit μπορεί να σας υπενθυμίζει συνδρομές και αιτήματα πληρωμής. + Ενεργοποιήστε τις ειδοποιήσεις για να λαμβάνετε υπενθυμίσεις για τις συνδρομές και τα αιτήματα πληρωμής σας. + Μείνε\n<accent>ενημερωμένος</accent> Ειδοποιήσεις Ανενεργό Ενεργό Ρύθμιση στο παρασκήνιο - Λήψη πληρωμών όταν το Bitkit είναι κλειστό - Πληρωμές παρασκηνίου + Να επιτρέπονται οι ειδοποιήσεις + Ειδοποιήσεις Βρίσκει ακριβείς αντιστοιχίσεις ποσού για ελαχιστοποίηση ρέστων Branch and Bound Τυχαία επιλογή για απόρρητο @@ -1271,6 +1257,7 @@ Το κανάλι δεν βρέθηκε Τα κεφάλαια στο υπόλοιπο δαπανών σας έχουν μεταφερθεί στις αποταμιεύσεις σας. Η Σύνδεση Έκλεισε + Μεταφορά από πορτοφόλι HW Προς αποταμιεύσεις Το ποσό είναι πολύ χαμηλό για μεταφορά στις αποταμιεύσεις. Κλείσιμο καναλιού αντί αυτού @@ -1294,6 +1281,9 @@ Υπόγραψε με\n<accent>τη συσκευή σου</accent> Η συναλλαγή\n<accent>υπογράφηκε</accent> Η ΜΕΤΑΦΟΡΑ ΕΙΝΑΙ ΕΤΟΙΜΗ ΣΕ %s + Μεταφορά μη διαθέσιμη + Οι αυτόματες μεταφορές στο υπόλοιπο δαπανών δεν είναι πλέον διαθέσιμες. Χρησιμοποιήστε τον δικό σας κόμβο ή ρυθμίστε σύνδεση με άλλον πάροχο. + <accent>Απαιτείται χειροκίνητη</accent>\nρύθμιση Βεβαιώσου ότι έχεις σταθερή σύνδεση στο διαδίκτυο. Τα δεδομένα μπορεί να εμφανίζονται λανθασμένα κατά την προσπάθεια σύνδεσης. Εντοπίστηκαν προβλήματα δικτύου Αυτός ο κωδικός QR δεν περιέχει έγκυρο SeedQR 12 λέξεων. @@ -1557,7 +1547,7 @@ Όνομα συνδρομής Σε εξέλιξη Επισκόπηση - Άνοιξε το 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 6b53d84af6..d3bb08a67a 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -9,8 +9,8 @@ Invitar Pagos instantáneos Gastar - Cuando Bitkit está cerrado - Recibe pagos + Recordatorios de pago + Notificaciones Establezca un código PIN PROTEGER Escanea y paga @@ -156,7 +156,6 @@ Hora de actualización de caché de tasas Comisiones Fuerzar transferencia - Algunas conexiones no pudieron cerrarse. No se han podido transferir tus fondos a ahorros. Por favor, inténtalo de nuevo. Transferencia forzada falló Sus fondos estarán accesibles en ±14 días. @@ -168,8 +167,6 @@ Usar Otro Monedero Saldo de gastos La forma más fácil es usar sus ahorros en Bitkit o enviar bitcoin desde un monedero diferente. - Bitkit actualmente no ofrece servicios Lightning en tu país, pero aún puedes conectarte directamente a otros nodos. - Bitkit actualmente no ofrece servicios Lightning en tu país, pero aún puedes conectarte directamente a otros nodos. Rellene su <accent>saldo de gastos</accent> QR de canal LNURL Configuración manual @@ -280,22 +277,11 @@ Por favor espera mientras se migran los datos de tu antiguo monedero a esta nueva versión de Bitkit. Esto suele tardar menos de un minuto. Migración del monedero MIGRANDO\n<accent>MONEDERO</accent> - Saldo movido de gasto a ahorros - Motivo: %s - Canal cerrado Canal de notificación para el servicio en primer plano del nodo Lightning Notificación del nodo Lightning - Error al abrir canal en segundo plano - Error al abrir canal - Canal abierto - Pendiente - Listo para enviar - Error de Lightning - Pago fallido - Inténtalo de nuevo Recibido %s Pago recibido - Bitkit se está ejecutando en segundo plano para que puedas recibir pagos Lightning + Bitkit se está ejecutando en segundo plano para que puedas recibir pagos y solicitudes Detener aplicación Avanzado Configuración avanzada @@ -610,16 +596,16 @@ Respalda tu monedero Copias de seguridad Personalizar en ajustes de Bitkit en Android - Los pagos en segundo plano están desactivados porque has denegado las notificaciones. - Los pagos en segundo plano están activados. Puedes recibir fondos incluso cuando la aplicación está cerrada (si tu dispositivo está conectado a internet). - Activa las notificaciones para recibir pagos, incluso cuando tu aplicación Bitkit está cerrada. - RECIBE PAGOS\n<accent>PASIVAMENTE</accent> + Las notificaciones están desactivadas porque las rechazaste. + Las notificaciones están activadas. Bitkit puede recordarte tus suscripciones y solicitudes de pago. + Activa las notificaciones para recibir recordatorios de tus suscripciones y solicitudes de pago. + Mantente\n<accent>informado</accent> Notificaciones Desactivado Activado Configurar en segundo plano - Recibe pagos cuando Bitkit está cerrado - Pagos en segundo plano + Permitir notificaciones + Notificaciones Encuentra coincidencias exactas para minimizar el cambio Ramificación y acotación Selección aleatoria para privacidad @@ -1287,6 +1273,7 @@ Este dispositivo contiene un monedero distinto del que vinculaste. Conecta el monedero vinculado o añade este como un nuevo monedero de hardware. Canal no encontrado Los fondos de tu saldo de gasto se han transferido a tus ahorros. + Transferir desde billetera HW A ahorros La cantidad es demasiado baja para transferirla a ahorros. Cerrar el canal en su lugar @@ -1310,6 +1297,9 @@ Firma con\n<accent>tu dispositivo</accent> Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s + Transferencia no disponible + Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + <accent>Configuración manual</accent>\nnecesaria Asegúrate de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados Este código QR no contiene un SeedQR válido de 12 palabras. @@ -1561,7 +1551,7 @@ Nombre de la suscripción En curso Resumen - Abre Bitkit para revisar un pago de suscripción. + Abre Bitkit para revisar el pago Pago de suscripción pendiente Métodos de pago Al día diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 44b48ab09b..4efcbb6f51 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -8,8 +8,8 @@ Invitar Pagos instantáneos Gastar - Cuando Bitkit está cerrado - Recibir pagos + Recordatorios de pago + Notificaciones Establezca un código PIN PROTEGER Escanear y pagar @@ -132,10 +132,7 @@ Tasa de comisión Hora de actualización de caché de comisiones Comisiones - Algunas conexiones no pudieron cerrarse. No se pueden transferir los fondos de vuelta a ahorros. Por favor, inténtalo de nuevo. - Bitkit no ofrece actualmente servicios Lightning en tu país, pero puedes conectarte a otros nodos directamente. - Bitkit no ofrece actualmente servicios Lightning en tu país, pero puedes conectarte a otros nodos directamente. QR de Canal LNURL Configuración Manual Escanea un QR para reclamar tu canal LNURL de otro LSP, o elige la configuración manual. @@ -278,22 +275,11 @@ Por favor espera mientras los datos de tu antiguo monedero migran a esta nueva versión de Bitkit. Esto normalmente tarda menos de un minuto. Migración del Monedero MIGRANDO\n<accent>MONEDERO</accent> - Saldo movido de gastos a ahorros - Motivo: %s - Canal cerrado Canal de notificaciones para el servicio en segundo plano del nodo Lightning Notificación del nodo Lightning - El canal no pudo abrirse en segundo plano - Apertura de canal fallida - Canal abierto - Pendiente - Listo para enviar - Error de Lightning - Pago fallido - Por favor, inténtalo de nuevo Recibido %s Pago Recibido - Bitkit se está ejecutando en segundo plano para que puedas recibir pagos Lightning + Bitkit se está ejecutando en segundo plano para que puedas recibir pagos y solicitudes Detener App Condiciones de uso Al continuar, declaras que has leído y aceptas los términos de uso. @@ -632,16 +618,16 @@ Fallo de Copia de Seguridad de Datos Copias de seguridad Personalizar en los Ajustes de Bitkit Android - Los pagos en segundo plano están desactivados porque has denegado las notificaciones. - Los pagos en segundo plano están activados. Puedes recibir fondos incluso cuando la aplicación está cerrada (si tu dispositivo está conectado a internet). - Activa las notificaciones para recibir pagos, incluso cuando tu aplicación Bitkit está cerrada. - RECIBE PAGOS\n<accent>PASIVAMENTE</accent> + Las notificaciones están desactivadas porque las rechazaste. + Las notificaciones están activadas. Bitkit puede recordarte tus suscripciones y solicitudes de pago. + Activa las notificaciones para recibir recordatorios de tus suscripciones y solicitudes de pago. + Mantente\n<accent>informado</accent> Notificaciones Desactivado Activado Configurar en segundo plano - Recibir pagos cuando Bitkit está cerrado - Pagos en Segundo Plano + Permitir notificaciones + Notificaciones Encuentra coincidencias de cantidad exacta para minimizar el cambio Branch and Bound Selección aleatoria para privacidad @@ -1286,6 +1272,7 @@ Canal no encontrado Los fondos de su saldo de gasto han sido transferidos a sus ahorros. Conexión Cerrada + Transferir desde billetera HW A ahorros La cantidad es demasiado baja para transferirla a ahorros. Cerrar el canal en su lugar @@ -1309,6 +1296,9 @@ Firme con\n<accent>su dispositivo</accent> Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s + Transferencia no disponible + Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. + <accent>Configuración manual</accent>\nnecesaria Asegúrese de tener una conexión a internet estable. Los datos pueden mostrarse de forma incorrecta mientras se intenta conectar. Problemas de red detectados Este código QR no contiene un SeedQR válido de 12 palabras. @@ -1561,7 +1551,7 @@ Nombre de la suscripción En curso Resumen - Abra Bitkit para revisar un pago de suscripción. + Abra Bitkit para revisar el pago Pago de suscripción pendiente Métodos de pago Al día diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 21739566ec..302b4a65f6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -19,8 +19,8 @@ Obtenir de l\'aide Acheter Achetez des bitcoins - Soyez rémunéré - Lorsque Bitkit est fermé + Notifications + Rappels de paiement Aperçu du portefeuille Cette fonctionnalité est actuellement en développement et sera bientôt disponible. Bientôt\n<accent>disponible</accent> @@ -106,8 +106,6 @@ Solde Dépenses Financez votre <accent>solde Dépenses</accent> Le plus simple est d\'utiliser vos économies Bitkit ou d\'envoyer des bitcoins depuis un autre portefeuille. - Bitkit ne fournit pas actuellement de services Lightning dans votre pays, mais vous pouvez toujours vous connecter directement à d\'autres nœuds. - Bitkit ne fournit pas actuellement de services Lightning dans votre pays, mais vous pouvez toujours vous connecter directement à d\'autres nœuds. Transfert depuis l\'épargne Utiliser un autre portefeuille Solde Dépenses @@ -260,7 +258,6 @@ Vos fonds seront accessibles dans un délai de ±14 jours. Échec du transfert forcé Impossible de renvoyer vos fonds sur l\'Épargne. Veuillez réessayer. - Certaines connexions n\'ont pas pu être fermées. Solde Dépenses Vous pouvez désormais payer n\'importe qui, n\'importe où, instantanément. Traitement du paiement @@ -280,22 +277,11 @@ Veuillez patienter pendant la migration de vos anciennes données vers cette nouvelle version de Bitkit. Cela prend généralement moins d\'une minute. Migration du portefeuille MIGRATION\n<accent>DU PORTEFEUILLE</accent> - Solde transféré des dépenses vers l\'épargne - Raison : %s - Canal fermé Canal de notification pour le service de nœud Lightning en arrière-plan Notification du nœud Lightning - Échec de l\'ouverture du canal en arrière-plan - Échec de l\'ouverture du canal - Canal ouvert - En attente - Prêt à envoyer - Erreur Lightning - Échec du paiement - Veuillez réessayer Reçu %s Paiement reçu - Bitkit fonctionne en arrière-plan pour que vous puissiez recevoir des paiements Lightning + Bitkit fonctionne en arrière-plan pour que vous puissiez recevoir des paiements et des demandes Arrêter l\'application Bitkit \n<accent>Conditions d\'utilisation</accent> Conditions d\'utilisation @@ -644,16 +630,16 @@ Contacts Sauvegardes des données Personnaliser dans les paramètres Android de Bitkit - Les paiements en arrière-plan sont désactivés car vous avez refusé les notifications. - Les paiements en arrière-plan sont activés. Vous pouvez recevoir des fonds même lorsque l\'application est fermée (si votre appareil est connecté à Internet). - Activez les notifications pour être payé, même lorsque votre application Bitkit est fermée. - ÊTRE PAYÉ\n<accent>PASSIVEMENT</accent> + Les notifications sont désactivées, car vous les avez refusées. + Les notifications sont activées. Bitkit peut vous rappeler vos abonnements et demandes de paiement. + Activez les notifications pour recevoir des rappels de vos abonnements et demandes de paiement. + Restez\n<accent>informé</accent> Notifications Désactivé Activé Configurer en arrière-plan - Être payé quand Bitkit est fermé - Paiements en arrière-plan + Autoriser les notifications + Notifications Besoin d\'aide ? Signalez votre problème depuis Bitkit, visitez le centre d\'aide, vérifiez le statut ou contactez-nous sur nos canaux sociaux. Rapporter un problème Centre d\'aide @@ -1289,6 +1275,7 @@ Échec de la vérification de l\'adresse. Vérifiez l\'adresse sur votre appareil et réessayez. Cet appareil contient un autre portefeuille que celui que vous avez associé. Connectez le portefeuille associé ou ajoutez celui-ci comme nouveau portefeuille matériel. Canal introuvable + Transfert depuis le portefeuille HW Vers l\'épargne Le montant est trop faible pour être transféré vers l\'épargne. Fermer plutôt le canal @@ -1312,6 +1299,9 @@ Signez avec\n<accent>votre appareil</accent> Transaction\n<accent>signée</accent> TRANSFERT PRÊT DANS %s + Transfert indisponible + Les transferts automatiques vers votre solde de dépenses ne sont plus disponibles. Utilisez votre propre nœud ou configurez une connexion avec un autre fournisseur. + <accent>Configuration manuelle</accent>\nrequise Veuillez vous assurer que vous disposez d\'une connexion internet stable. Les données peuvent s\'afficher incorrectement pendant la tentative de connexion. Problèmes de réseau détectés Ce QR code ne contient pas de SeedQR valide de 12 mots. @@ -1562,7 +1552,7 @@ Nom de l\'abonnement En cours Aperçu - Ouvrez Bitkit pour examiner un paiement d\'abonnement. + Ouvrez Bitkit pour examiner le paiement Paiement d\'abonnement dû Moyens de paiement Par jour diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9640894d20..65f988dc0e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -9,8 +9,8 @@ Invita Pagamenti istantanei Spendi - Quando Bitkit e\' chiuso - Ricevi pagamenti + Promemoria di pagamento + Notifiche Imposta un codice PIN Sicuro Scansiona e paga @@ -155,7 +155,6 @@ Tempo di Aggiornamento Cache Commissioni Commissioni Trasferimento Forzato - Alcune connessioni non sono state chiuse. Impossibile trasferire nuovamente i fondi nei risparmi. Per favore riprova. Trasferimento Forzato Fallito I tuoi fondi saranno accessibili in ±14 giorni. @@ -167,8 +166,6 @@ Usa un altro portafoglio Conto di Spesa Il modo più semplice è usare i tuoi risparmi Bitkit o inviare bitcoin da un portafoglio diverso. - Bitkit attualmente non fornisce servizi Lightning nel tuo paese, ma puoi comunque connetterti direttamente ad altri nodi. - Bitkit attualmente non fornisce servizi Lightning nel tuo paese, ma puoi comunque connetterti direttamente ad altri nodi. Manda fondi al tuo <accent>conto di spesa</accent> QR Canale LNURL Configurazione Manuale @@ -279,22 +276,11 @@ Attendi mentre i dati del tuo vecchio wallet migrano a questa nuova versione di Bitkit. Questo di solito richiede meno di un minuto. Migrazione Wallet MIGRAZIONE\n<accent>WALLET</accent> - Saldo spostato dalle spese ai risparmi - Motivo: %s - Canale chiuso Canale di notifica per il servizio in primo piano del nodo Lightning Notifica nodo Lightning - Apertura canale in background non riuscita - Apertura canale non riuscita - Canale aperto - In attesa - Pronto per inviare - Errore Lightning - Pagamento fallito - Per favore riprova Ricevuto %s Pagamento Ricevuto - Bitkit e\' in esecuzione in background per permetterti di ricevere pagamenti Lightning + Bitkit è in esecuzione in background per permetterti di ricevere pagamenti e richieste Ferma App Avanzate Impostazioni Avanzate @@ -609,16 +595,16 @@ Esegui il backup del tuo wallet Backup dei dati Personalizza nelle Impostazioni Android di Bitkit - I pagamenti in background sono disabilitati perche\' hai negato le notifiche. - I pagamenti in background sono abilitati. Puoi ricevere fondi anche quando l\'app e\' chiusa (se il dispositivo e\' connesso a internet). - Attiva le notifiche per ricevere pagamenti anche quando l\'app Bitkit e\' chiusa. - RICEVI PAGAMENTI\n<accent>PASSIVAMENTE</accent> + Le notifiche sono disattivate perché le hai rifiutate. + Le notifiche sono attive. Bitkit può ricordarti abbonamenti e richieste di pagamento. + Attiva le notifiche per ricevere promemoria su abbonamenti e richieste di pagamento. + Resta\n<accent>aggiornato</accent> Notifiche Disattivo Attivo Configura in background - Ricevi pagamenti quando Bitkit e\' chiuso - Pagamenti in Background + Consenti notifiche + Notifiche Trova corrispondenze esatte per minimizzare il resto Branch and Bound Selezione casuale per la privacy @@ -1292,6 +1278,7 @@ Questo dispositivo contiene un wallet diverso da quello associato. Connetti il wallet associato o aggiungi questo come nuovo hardware wallet. Canale non trovato I fondi sul tuo saldo di spesa sono stati trasferiti ai tuoi risparmi. + Trasferimento da wallet HW Ai risparmi L\'importo è troppo basso per essere trasferito ai risparmi. Chiudi invece il canale @@ -1315,6 +1302,9 @@ Firma con\n<accent>il tuo dispositivo</accent> Transazione\n<accent>firmata</accent> TRASFERIMENTO PRONTO TRA %s + Trasferimento non disponibile + I trasferimenti automatici al saldo di spesa non sono più disponibili. Usa il tuo nodo o configura una connessione con un altro fornitore. + <accent>Configurazione manuale</accent>\nrichiesta Assicurati di avere una connessione a internet stabile. I dati potrebbero essere mostrati in modo errato durante il tentativo di connessione. Rilevati Problemi di Rete Questo codice QR non contiene un SeedQR valido da 12 parole. @@ -1565,7 +1555,7 @@ Nome abbonamento In corso Panoramica - Apri Bitkit per controllare un pagamento di abbonamento. + Apri Bitkit per controllare il pagamento Pagamento Abbonamento Dovuto Metodi di pagamento Al Giorno diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index c1309d8df6..88e6da0580 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -9,8 +9,8 @@ Uitnodiging Directe betalingen Besteed - Als Bitkit gesloten is - Ontvang betaling + Betaalherinneringen + Meldingen Pincode instellen Beveilig Scan en betaal @@ -154,7 +154,6 @@ Vergoedingstarief cache update tijd Vergoedingen Forceer overboeking - Sommige verbindingen konden niet worden gesloten. Kon het geld niet terugboeken naar spaargeld. Probeer het opnieuw. Geforceerde overboeking mislukt Je geld is beschikbaar in ±14 dagen. @@ -166,8 +165,6 @@ Andere wallet gebruiken Bestedingssaldo De makkelijkste optie is om je Bitkit-spaargeld te gebruiken of bitcoin te versturen vanuit een andere wallet. - Bitkit biedt momenteel geen Lightning-services in jouw land, maar je kunt nog steeds rechtstreeks verbinding maken met andere nodes. - Bitkit biedt momenteel geen Lightning-services in jouw land, maar je kunt nog steeds rechtstreeks verbinding maken met andere nodes. Vul je <accent>bestedingssaldo</accent> LNURL Kanaal QR Handmatige configuratie @@ -278,22 +275,11 @@ Even geduld terwijl je oude wallet-gegevens migreren naar deze nieuwe Bitkit-versie. Dit duurt meestal minder dan een minuut. Wallet-migratie WALLET\n<accent>MIGREREN</accent> - Saldo verplaatst van bestedingssaldo naar spaargeld - Reden: %s - Kanaal gesloten Notificatiekanaal voor Lightning node voorgrondservice Lightning node notificatie - Kanaal kon niet op de achtergrond worden geopend - Kanaal openen mislukt - Kanaal geopend - In behandeling - Klaar om te versturen - Lightning-fout - Betaling mislukt - Probeer het opnieuw %s ontvangen Betaling ontvangen - Bitkit draait op de achtergrond zodat je Lightning-betalingen kunt ontvangen + Bitkit draait op de achtergrond zodat je betalingen en verzoeken kunt ontvangen App stoppen Geavanceerd Geavanceerde configuratie @@ -607,16 +593,16 @@ Back-up maken van je wallet Gegevensback-ups Aanpassen in Android Bitkit-instellingen - Achtergrondbetalingen zijn uitgeschakeld omdat je notificaties hebt geweigerd. - Achtergrondbetalingen zijn ingeschakeld. Je kunt geld ontvangen zelfs als de app gesloten is (als je apparaat verbonden is met internet). - Schakel notificaties in om betaald te worden, zelfs als je Bitkit-app gesloten is. - ONTVANG BETALING\n<accent>PASSIEF</accent> + Meldingen staan uit, omdat je ze hebt geweigerd. + Meldingen staan aan. Bitkit kan je herinneren aan abonnementen en betaalverzoeken. + Zet meldingen aan om herinneringen te krijgen voor je abonnementen en betaalverzoeken. + Blijf\n<accent>op de hoogte</accent> Notificaties Uit Aan Op achtergrond instellen - Ontvang betaling als Bitkit gesloten is - Achtergrondbetalingen + Meldingen toestaan + Meldingen Vindt exacte bedragovereenkomsten om wisselgeld te minimaliseren Branch and Bound Willekeurige selectie voor privacy @@ -1288,6 +1274,7 @@ Kanaal niet gevonden Het geld op uw bestedingssaldo is overgemaakt naar uw spaargeld. Verbinding Gesloten + Overboeken van HW-wallet Naar spaargeld Bedrag is te laag om naar spaargeld over te boeken. In plaats daarvan kanaal sluiten @@ -1311,6 +1298,9 @@ Onderteken met\n<accent>uw apparaat</accent> Transactie\n<accent>ondertekend</accent> OVERBOEKING KLAAR OVER %s + Overboeking niet beschikbaar + Automatische overboekingen naar je bestedingssaldo zijn niet meer beschikbaar. Gebruik je eigen node of stel een verbinding in met een andere aanbieder. + <accent>Handmatige instelling</accent>\nvereist Zorg voor een stabiele internetverbinding. Gegevens kunnen onjuist worden weergegeven terwijl er verbinding wordt gemaakt. Netwerkproblemen gedetecteerd Deze QR-code bevat geen geldige SeedQR van 12 woorden. @@ -1563,7 +1553,7 @@ Naam abonnement Doorlopend Overzicht - Open Bitkit om een abonnementsbetaling te bekijken. + Open Bitkit om de betaling te bekijken Abonnementsbetaling verschuldigd Betaalmethoden Per dag diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1f0efff06a..e35e50dd5e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -19,8 +19,8 @@ Otrzymaj wsparcie Kup Kup trochę bitcoina - Gdy Bitkit jest zamknięty - Odbieraj płatności + Przypomnienia o płatnościach + Powiadomienia Przegląd portfela Ta funkcja jest w trakcie rozwoju i wkrótce będzie dostępna. Wkrótce\n<accent>dostępne</accent> @@ -108,8 +108,6 @@ Saldo wydatków Zasil swoje <accent>saldo wydatków</accent> Najłatwiejszą opcją jest użycie oszczędności Bitkit lub wysłanie bitcoinów z innego portfela. - Bitkit nie świadczy obecnie usług Lightning w Twoim kraju, ale nadal możesz łączyć się z innymi węzłami. - Bitkit nie świadczy obecnie usług Lightning w Twoim kraju, ale nadal możesz łączyć się bezpośrednio z innymi węzłami. Prześlij z Oszczędności Użyj innego portfela Saldo do wydawania @@ -252,7 +250,6 @@ Wymuszenie transferu zainicjowane Środki będą dostępne w ciągu ±14 dni. Wymuszenie transferu nie powiodło się - Niektórych połączeń nie można było zamknąć. Nie można przelać środków z powrotem na saldo oszczędności. Proszę spróbować ponownie. Saldo wydatków gotowe Teraz możesz zapłacić każdemu, gdziekolwiek, natychmiast. @@ -282,22 +279,11 @@ Proszę czekać, trwa migracja danych starego portfela do nowej wersji Bitkit. Zwykle trwa to mniej niż minutę. Migracja portfela MIGRACJA\n<accent>PORTFELA</accent> - Saldo przeniesione z wydatków do oszczędności - Powód: %s - Kanał zamknięty Kanał powiadomień dla usługi Lightning w tle Powiadomienie węzła Lightning - Nie udało się otworzyć kanału w tle - Otwarcie kanału nie powiodło się - Kanał otwarty - Oczekujące - Gotowy do wysłania - Błąd Lightning - Płatność nie powiodła się - Proszę spróbować ponownie Otrzymano %s Płatność otrzymana - Bitkit działa w tle, abyś mógł otrzymywać płatności Lightning + Bitkit działa w tle, abyś mógł otrzymywać płatności i prośby Zatrzymaj aplikację Bitkit\n<accent>warunki użytkowania</accent> Warunki użytkowania @@ -644,16 +630,16 @@ W toku Kopie zapasowe danych Dostosuj w ustawieniach Bitkit na Androidzie - Płatności w tle są wyłączone, ponieważ odmówiono powiadomień. - Płatności w tle są włączone. Możesz otrzymywać środki nawet gdy aplikacja jest zamknięta (jeśli Twoje urządzenie jest połączone z internetem). - Włącz powiadomienia, aby otrzymywać płatności nawet gdy Bitkit jest zamknięty. - OTRZYMUJ\n<accent>PASYWNIE</accent> + Powiadomienia są wyłączone, ponieważ ich odmówiono. + Powiadomienia są włączone. Bitkit może przypominać o subskrypcjach i prośbach o płatność. + Włącz powiadomienia, aby otrzymywać przypomnienia o subskrypcjach i prośbach o płatność. + Bądź\n<accent>na bieżąco</accent> Powiadomienia Wył. Wł. Konfiguracja w tle - Otrzymuj płatności gdy Bitkit jest zamknięty - Płatności w tle + Zezwól na powiadomienia + Powiadomienia Znajduje dokładne dopasowania kwot, aby zminimalizować resztę Branch and Bound Losowy wybór dla prywatności @@ -1292,6 +1278,7 @@ Weryfikacja adresu nie powiodła się. Sprawdź adres na urządzeniu i spróbuj ponownie. To urządzenie zawiera inny portfel niż sparowany. Połącz sparowany portfel lub dodaj ten jako nowy portfel sprzętowy. Nie znaleziono kanału + Prześlij z portfela HW Na oszczędności Kwota jest zbyt niska, aby przesłać ją na oszczędności. Zamiast tego zamknij kanał @@ -1315,6 +1302,9 @@ Podpisz\n<accent>urządzeniem</accent> Transakcja\n<accent>podpisana</accent> TRANSFER GOTOWY ZA %s + Przesłanie niedostępne + Automatyczne przesyłanie środków na saldo wydatków nie jest już dostępne. Użyj własnego węzła lub skonfiguruj połączenie z innym dostawcą. + <accent>Wymagana ręczna</accent>\nkonfiguracja Upewnij się, że masz stabilne połączenie z internetem. Podczas łączenia dane mogą być wyświetlane nieprawidłowo. Wykryto problemy z siecią Ten kod QR nie zawiera prawidłowego 12-wyrazowego SeedQR. @@ -1564,7 +1554,7 @@ Nazwa subskrypcji W toku Przegląd - Otwórz Bitkit, aby przejrzeć płatność za subskrypcję. + Otwórz Bitkit, aby przejrzeć płatność Termin płatności za subskrypcję Metody płatności Dziennie diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 2ba1218bb3..41ed89d286 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -9,8 +9,8 @@ Convide Pagamentos instantâneos Pagar - Quando a Bitkit está fechada - Receba pagamentos + Lembretes de pagamento + Notificações Configure um PIN Segurança Escanear e pagar @@ -158,7 +158,6 @@ Atualização do Cache de Taxa Taxas Forçar Transferência - Algumas conexões não puderam ser fechadas. Não foi possível transferir seus fundos de volta para sua poupança. Por favor, tente novamente. Falha na transferência forçada Seus fundos estarão disponíveis em ±14 dias. @@ -170,8 +169,6 @@ Transferir de Outra Carteira Saldo de Gastos A forma mais fácil é usar suas economias do Bitkit ou enviar bitcoin de uma carteira diferente. - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes Transfira para seu <accent>saldo de gastos</accent> QR Canal LNURL Setup Manual @@ -282,22 +279,11 @@ Aguarde enquanto os dados da sua carteira antiga são migrados para esta nova versão da Bitkit. Isso geralmente leva menos de um minuto. Migração da Carteira MIGRANDO\n<accent>CARTEIRA</accent> - Saldo movido de gastos para poupança - Motivo: %s - Canal fechado Canal de notificação para serviço em primeiro plano do nó Lightning Notificação do nó Lightning - Falha ao abrir canal em segundo plano - Falha ao abrir canal - Canal aberto - Pendente - Pronto para enviar - Erro Lightning - Pagamento falhou - Tente novamente Recebido %s Pagamento Recebido - Bitkit está executando em segundo plano para que você possa receber pagamentos Lightning + Bitkit está executando em segundo plano para que você possa receber pagamentos e solicitações Parar Aplicativo Avançado Configuração Avançada @@ -614,16 +600,16 @@ Faça backup da sua carteira Backups de dados Personalizar nas Configurações do Bitkit Android - Pagamentos em segundo plano estão desativados porque você negou notificações. - Pagamentos em segundo plano estão ativados. Você pode receber fundos mesmo quando o app está fechado (se seu dispositivo estiver conectado à internet). - Ative as notificações para receber pagamentos, mesmo quando o app Bitkit está fechado. - RECEBA\n<accent>PASSIVAMENTE</accent> + As notificações estão desativadas porque você as recusou. + As notificações estão ativadas. O Bitkit pode lembrar você de assinaturas e solicitações de pagamento. + Ative as notificações para receber lembretes das suas assinaturas e solicitações de pagamento. + Fique\n<accent>por dentro</accent> Notificações Desligado Ligado Configurar em segundo plano - Receba pagamentos quando a Bitkit está fechada - Pagamentos em Segundo Plano + Permitir notificações + Notificações Encontra correspondências exatas para minimizar troco Branch and Bound Seleção aleatória para privacidade @@ -1294,6 +1280,7 @@ Falha na verificação do endereço. Confira o endereço no seu dispositivo e tente novamente. Este dispositivo contém uma carteira diferente da que você pareou. Conecte a carteira pareada ou adicione esta como uma nova carteira de hardware. Canal não encontrado + Transferir da carteira HW Para poupança O valor é baixo demais para transferir para a poupança. Fechar o canal em vez disso @@ -1317,6 +1304,9 @@ Assine com\n<accent>seu dispositivo</accent> Transação\n<accent>assinada</accent> TRANSFERÊNCIA PRONTA EM %s + Transferência indisponível + As transferências automáticas para seu saldo de gastos não estão mais disponíveis. Use seu próprio nó ou configure uma conexão com outro provedor. + <accent>Configuração manual</accent>\nnecessária Verifique se você tem uma conexão estável com a internet. Os dados podem ser exibidos incorretamente enquanto tentamos conectar. Problemas de Rede Detectados Este código QR não contém um SeedQR válido de 12 palavras. @@ -1565,7 +1555,7 @@ Nome da assinatura Em andamento Visão Geral - Abra a Bitkit para revisar um pagamento de assinatura. + Abra a Bitkit para revisar o pagamento Pagamento de Assinatura Pendente Formas de pagamento Por Dia diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 18f8c590ce..eb0581b85d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -19,8 +19,8 @@ Peça ajuda Comprar Compre bitcoin - Quando a Bitkit estiver fechada - Receba pagamentos + Lembretes de pagamento + Notificações Visão geral da carteira Este recurso está em desenvolvimento e estará disponível em breve. Em\n<accent>breve</accent> @@ -103,8 +103,6 @@ Saldo de Gastos Transfira para seu <accent>saldo de gastos</accent> A forma mais fácil é usar as suas poupanças Bitkit ou enviar bitcoin de uma carteira diferente. - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes - Bitkit não fornece serviços de Lightning no seu paús atualmente, mas você ainda pode se conectar diretamente a outros nodes Transferir do Saldo de Poupança Transferir de Outra Carteira Saldo de Gastos @@ -267,7 +265,6 @@ Máximo do HTLC de Entrada Mínimo do HTLC de Entrada Altura do Arquivo do Monitor de Canal - Algumas conexões não puderam ser fechadas. TRANSFERÊNCIA EM ANDAMENTO Começar Configure o seu saldo de despesas para desfrutar de transações instantâneas e económicas com amigos, familiares e comerciantes. @@ -275,22 +272,11 @@ Por favor aguarde enquanto seus dados da carteira antiga migram para esta nova versão da Bitkit. Isso geralmente leva menos de um minuto. Migração da Carteira MIGRANDO\n<accent>CARTEIRA</accent> - Saldo movido de gastos para poupança - Motivo: %s - Canal fechado Canal de notificação para o serviço em primeiro plano do nó Lightning Notificação do nó Lightning - Canal falhou ao abrir em segundo plano - Falha na abertura do canal - Canal aberto - Pendente - Pronto para enviar - Erro Lightning - Pagamento falhou - Por favor, tente novamente Recebido %s Pagamento Recebido - A Bitkit está rodando em segundo plano para você receber pagamentos Lightning + A Bitkit está a ser executada em segundo plano para poder receber pagamentos e pedidos Parar App Bitkit\n<accent>termos de uso</accent> Termos de uso @@ -728,16 +714,16 @@ Servidor de Rapid-Gossip-Sync Atualizado Pode ser necessário reiniciar o aplicativo uma ou duas vezes para que esta alteração seja aplicada. Personalizar nas Configurações da Bitkit no Android - Os pagamentos em segundo plano estão desativados porque você negou as notificações. - Pagamentos em segundo plano estão ativados. Você pode receber fundos mesmo quando o app está fechado (se seu dispositivo estiver conectado à internet). - Ative as notificações para receber pagamentos, mesmo quando a Bitkit está fechada. - RECEBA\n<accent>PASSIVAMENTE</accent> + As notificações estão desativadas porque as recusou. + As notificações estão ativadas. A Bitkit pode lembrá-lo de subscrições e pedidos de pagamento. + Ative as notificações para receber lembretes das suas subscrições e pedidos de pagamento. + Mantenha-se\n<accent>informado</accent> Notificações Desativado Ativado Configurar em segundo plano - Receba pagamentos com a Bitkit fechada - Pagamentos em Segundo Plano + Permitir notificações + Notificações Encontra correspondências exatas para minimizar troco Branch and Bound Seleção aleatória para privacidade @@ -1263,6 +1249,7 @@ Canal não encontrado Os fundos do seu saldo de gastos foram transferidos para a sua poupança. Ligação fechada + Transferir da carteira HW Para a poupança O montante é demasiado baixo para transferir para a poupança. Fechar o canal @@ -1286,6 +1273,9 @@ Assine com\n<accent>o seu dispositivo</accent> Transação\n<accent>assinada</accent> TRANSFERÊNCIA PRONTA EM %s + Transferência indisponível + As transferências automáticas para o seu saldo de gastos já não estão disponíveis. Use o seu próprio nó ou configure uma ligação com outro fornecedor. + <accent>Configuração manual</accent>\nnecessária Confirme que tem uma ligação à internet estável. Os dados podem ser apresentados incorretamente enquanto tenta ligar. Problemas de rede detetados Este código QR não contém um SeedQR válido de 12 palavras. @@ -1551,7 +1541,7 @@ Nome da subscrição Em curso Vista geral - Abra a Bitkit para rever um pagamento de subscrição. + Abra a Bitkit para rever o pagamento Pagamento de subscrição pendente Métodos de pagamento Por dia diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f3e9f37990..fc49a76ef7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -9,8 +9,8 @@ Пригласить Мгновенные платежи Тратить - Когда Bitkit закрыт - Получать платежи + Напоминания об оплате + Уведомления Установите PIN-код Безопасность Сканируйте и платите @@ -166,7 +166,6 @@ Время обновления кэша комиссии Комиссии Принудительный Перевод - Некоторые соединения не удалось закрыть. Не удалось перевести ваши средства обратно в сбережения. Пожалуйста, попробуйте снова. Неудачный Принудительный Перевод Ваши средства будут доступны через ±14 дней. @@ -178,8 +177,6 @@ Использовать Другой Кошелёк Баланс Расходов Самый простой вариант — использовать ваши сбережения Bitkit или отправить биткоин с другого кошелька. - Bitkit в настоящее время не предоставляет услуги Lightning в вашей стране, но вы всё ещё можете подключаться к другим нодам напрямую. - Bitkit в настоящее время не предоставляет услуги Lightning в вашей стране, но вы всё ещё можете подключаться к другим нодам напрямую. Пополните свой <accent>Баланс Расходов</accent> QR LNURL Канала Ручная Настройка @@ -295,22 +292,11 @@ Миграция кошелька МИГРАЦИЯ <accent>КОШЕЛЬКА</accent> - Баланс перемещён с расходов на сбережения - Причина: %s - Канал закрыт Канал уведомлений для фоновой службы Lightning ноды Уведомление Lightning ноды - Не удалось открыть канал в фоновом режиме - Не удалось открыть канал - Канал открыт - В ожидании - Готов к отправке - Ошибка Lightning - Платёж не удался - Пожалуйста, попробуйте снова Получено %s Платёж получен - Bitkit работает в фоновом режиме, чтобы вы могли получать Lightning платежи + Bitkit работает в фоновом режиме, чтобы вы могли получать платежи и запросы Остановить приложение Дополнительно Дополнительно @@ -629,17 +615,16 @@ Создать Резервную Копию Кошелька Резервные копии данных Настроить в настройках Android Bitkit - Фоновые платежи отключены, потому что вы отклонили уведомления. - Фоновые платежи включены. Вы можете получать средства, даже когда приложение закрыто (если ваше устройство подключено к интернету). - Включите уведомления, чтобы получать платежи, даже когда приложение Bitkit закрыто. - ПОЛУЧАЙТЕ -<accent>ПАССИВНО</accent> + Уведомления отключены, потому что вы их запретили. + Уведомления включены. Bitkit может напоминать о подписках и запросах на оплату. + Включите уведомления, чтобы получать напоминания о подписках и запросах на оплату. + Будьте\n<accent>в курсе</accent> Уведомления Выкл Вкл Настроить в фоне - Получать платежи при закрытом Bitkit - Фоновые платежи + Разрешить уведомления + Уведомления Находит точные совпадения сумм для минимизации сдачи Branch and Bound Случайный выбор для приватности @@ -1323,6 +1308,7 @@ Bitkit должен увеличить приёмную ёмкость ваше Проверка адреса не удалась. Сверьте адрес на устройстве и попробуйте снова. На этом устройстве другой кошелёк, а не тот, который вы сопрягли. Подключите сопряжённый кошелёк или добавьте этот как новый аппаратный кошелёк. Канал не найден + Перевести с аппаратного кошелька В сбережения Сумма слишком мала для перевода в сбережения. Вместо этого закрыть канал @@ -1346,6 +1332,9 @@ Bitkit должен увеличить приёмную ёмкость ваше Подпишите на\n<accent>устройстве</accent> Транзакция\n<accent>подписана</accent> ПЕРЕВОД БУДЕТ ГОТОВ ЧЕРЕЗ %s + Перевод недоступен + Автоматические переводы на баланс расходов больше недоступны. Используйте собственный узел или настройте подключение к другому провайдеру. + <accent>Требуется ручная</accent>\nнастройка Убедитесь, что у вас стабильное подключение к интернету. Во время подключения данные могут отображаться некорректно. Обнаружены Проблемы с Сетью Этот QR-код не содержит действительный SeedQR из 12 слов. @@ -1593,7 +1582,7 @@ Bitkit должен увеличить приёмную ёмкость ваше Название подписки Бессрочно Обзор - Откройте Bitkit, чтобы проверить платёж по подписке. + Откройте Bitkit, чтобы проверить платёж Пора Оплатить Подписку Способы оплаты В День diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4738bdfd0..12a14a4f9e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,8 +38,8 @@ Invite Instant payments Spend - When Bitkit is closed - Get paid + Payment reminders + Notifications Set up a PIN code Secure Scan and pay @@ -303,7 +303,6 @@ Fee Rate Cache Update Time Fees Force Transfer - Some connections could not be closed. Unable to transfer your funds back to savings. Please try again. Force Transfer Failed Your funds will be accessible in ±14 days. @@ -313,10 +312,9 @@ Force\n<accent>Transfer</accent> Transfer from Savings Use Other Wallet + Transfer from HW wallet Spending Balance The easiest option is to use your Bitkit savings or send bitcoin from a different wallet. - Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes directly. - Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes directly. Fund your <accent>spending balance</accent> LNURL Channel QR Manual Setup @@ -442,6 +440,9 @@ Your funds have been transferred and your spending balance is ready to use. Funds moved\n<accent>to savings</accent> Funds moved\n<accent>to spending</accent> + Transfer Unavailable + Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider. + <accent>Manual setup</accent>\nrequired Please wait for Bitkit to connect to the payment network (±10 seconds). Wallet Balances Please wait while your old wallet data migrates to this new Bitkit version. This usually takes less than a minute. @@ -449,22 +450,11 @@ Please ensure you have a stable internet connection. Data may show incorrectly while trying to connect. Network Issues Detected MIGRATING\n<accent>WALLET</accent> - Balance moved from spending to savings - Reason: %s - Channel closed Notification channel for Lightning node foreground service Lightning node notification - Channel failed to open in the background - Channel open failed - Channel opened - Pending - Ready to send - Lightning error - Payment failed - Please try again Received %s Payment Received - Bitkit is running in background so you can receive Lightning payments + Bitkit is running in the background so you can receive payments and requests Stop App Advanced Advanced Setup @@ -924,19 +914,19 @@ Latest Backup: {time} Back up your wallet Customize in Android Bitkit Settings - Background payments are disabled, because you have denied notifications. - Background payments are enabled. You can receive funds even when the app is closed (if your device is connected to the internet). + Notifications are off, because you have denied them. + Notifications are on. Bitkit can remind you about subscriptions and payment requests. Enable - Turn on notifications to get paid, even when your Bitkit app is closed. - GET PAID\n<accent>PASSIVELY</accent> + Turn on notifications to get reminders for your subscriptions and payment requests. + Stay\n<accent>notified</accent> Keeping Bitkit active results in more reliable payments. Android may show a persistent notification while this is enabled. Keep Bitkit active in background Notifications Off On Set up in background - Get paid when Bitkit is closed - Background Payments + Allow notifications + Notifications Finds exact amount matches to minimize change Branch and Bound Random selection for privacy @@ -1180,7 +1170,7 @@ Subscription name Ongoing Overview - Open Bitkit to review a subscription payment. + Open Bitkit to review payment Subscription Payment Due Payment methods Payments @@ -1623,6 +1613,7 @@ This payment may still reach the network, so it cannot be cancelled. Send this payment\'s coins back to your wallet? Network fee: {fee} sats. Bitkit could not sync with the network. Check your connection and try again. + Transfers to spending are no longer available. Bitkit first checks the network, then sends this transfer\'s coins back to your wallet so the original transfer can never confirm. This costs a network fee. The new payment was not sent. Finish the previous payment before trying again. This payment is taking a bit longer than expected. You can continue using Bitkit. Payment Pending diff --git a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt deleted file mode 100644 index a4c692524d..0000000000 --- a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt +++ /dev/null @@ -1,248 +0,0 @@ -package to.bitkit.fcm - -import android.Manifest -import android.app.Activity -import android.app.Application -import android.app.Notification -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.work.ListenableWorker -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import com.synonym.bitkitcore.IcJitEntry -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.Event -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doSuspendableAnswer -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import to.bitkit.App -import to.bitkit.CurrentActivity -import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.domain.commands.ReceivedNotificationContent -import to.bitkit.ext.createChannelDetails -import to.bitkit.ext.mock -import to.bitkit.ext.notificationManager -import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.NotificationDetails -import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.LightningRepo -import to.bitkit.services.NodeEventHandler -import to.bitkit.services.NodeServiceFgState -import to.bitkit.test.BaseUnitTest -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class WakeNodeWorkerTest : BaseUnitTest() { - private val context = ApplicationProvider.getApplicationContext() - private val workerParams = mock() - private val lightningRepo = mock() - private val blocktankRepo = mock() - private val activityRepo = mock() - private val cacheStore = mock() - private val receivedNotificationContent = mock() - private val nodeServiceFgState = NodeServiceFgState() - - private val channelId = "channel-1" - private val receivedTitle by lazy { context.getString(R.string.notification__received__title) } - - @Before - fun setUp() { - whenever(workerParams.inputData).thenReturn( - workDataOf("type" to BlocktankNotificationType.cjitPaymentArrived.name), - ) - - val app = context as Application - Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) - - // Default: app killed (no foreground activity); nodeServiceFgState defaults to not-running - App.currentActivity = CurrentActivity() - } - - @After - fun tearDown() { - App.currentActivity = null - } - - @Test - fun `cjit channel ready delivers rich notification content with fiat`() = test { - val body = $$"Received ₿ 48 064 ($30.79)" - whenever(receivedNotificationContent.build(48_064L)).thenReturn(NotificationDetails(receivedTitle, body)) - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - val notification = findNotificationByTitle(receivedTitle) - assertNotNull(notification, "CJIT notification should be delivered when app is killed") - assertEquals(body, notification.extras?.getString(Notification.EXTRA_TEXT)) - verify(activityRepo).insertActivityFromCjit(any(), any()) - } - - @Test - fun `non-cjit channel ready delivers no payment notification`() = test { - val channel = cjitChannel(sats = 45_000) - stubChannel(channel, cjitEntry = null) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "A non-CJIT channel must not show a payment notification") - verify(activityRepo, never()).insertActivityFromCjit(any(), any()) - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `cjit channel ready skips notification when foreground service is running`() = test { - nodeServiceFgState.setForegroundServiceRunning(true) - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "Deduped when the foreground service handles it") - // Defers entirely: the in-process handler owns the insert, so the worker must not win the dedup race - verify(activityRepo, never()).insertActivityFromCjit(any(), any()) - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `cjit channel ready skips notification when activity already exists`() = test { - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(false)) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "A duplicate CJIT channel ready must not notify again") - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `cjit channel ready skips notification when app is in foreground`() = test { - App.currentActivity?.onActivityStarted(mock()) - val channel = cjitChannel(sats = 48_064) - stubChannel(channel, cjitEntry = IcJitEntry.mock()) - stubStartFiring(channelReadyEvent()) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull(findNotificationByTitle(receivedTitle), "Notification is deduped when the in-app UI handles it") - // Defers entirely: the in-app handler owns the insert, so the worker must not win the dedup race - verify(activityRepo, never()).insertActivityFromCjit(any(), any()) - verify(cacheStore, never()).setBackgroundReceive(any()) - } - - @Test - fun `payment received delivers rich notification content with fiat`() = test { - val title = context.getString(R.string.notification__received__title) - val body = $$"Received ₿ 48 064 ($30.79)" - whenever(receivedNotificationContent.build(48_064L)).thenReturn(NotificationDetails(title, body)) - whenever(workerParams.inputData).thenReturn( - workDataOf("type" to BlocktankNotificationType.incomingHtlc.name), - ) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - stubStartFiring(paymentReceivedEvent(sats = 48_064)) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - val notification = findNotificationByTitle(title) - assertNotNull(notification, "Payment notification should be delivered when app is killed") - assertEquals(body, notification.extras?.getString(Notification.EXTRA_TEXT)) - } - - @Test - fun `payment received skips notification when foreground service is running`() = test { - nodeServiceFgState.setForegroundServiceRunning(true) - whenever(workerParams.inputData).thenReturn( - workDataOf("type" to BlocktankNotificationType.incomingHtlc.name), - ) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - stubStartFiring(paymentReceivedEvent(sats = 48_064)) - - val result = worker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - assertNull( - findNotificationByTitle(context.getString(R.string.notification__received__title)), - "Notification is deduped when the foreground service handles it", - ) - // Receive is still cached for the in-app UI to pick up - verify(cacheStore).setBackgroundReceive(any()) - } - - private fun worker() = WakeNodeWorker( - appContext = context, - workerParams = workerParams, - lightningRepo = lightningRepo, - blocktankRepo = blocktankRepo, - activityRepo = activityRepo, - cacheStore = cacheStore, - receivedNotificationContent = receivedNotificationContent, - nodeServiceFgState = nodeServiceFgState, - ) - - private fun channelReadyEvent() = mock { - on { this.channelId } doReturn this@WakeNodeWorkerTest.channelId - } - - private fun paymentReceivedEvent(sats: Long) = Event.PaymentReceived( - paymentId = "payment-1", - paymentHash = "hash-1", - amountMsat = (sats * 1000).toULong(), - customRecords = emptyList(), - ) - - private fun cjitChannel(sats: Long) = createChannelDetails().copy( - channelId = channelId, - outboundCapacityMsat = (sats * 1000).toULong(), - unspendablePunishmentReserve = 0u, - ) - - private suspend fun stubChannel(channel: ChannelDetails, cjitEntry: IcJitEntry?) { - whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) - whenever(blocktankRepo.getCjitEntry(channel)).thenReturn(cjitEntry) - whenever(activityRepo.insertActivityFromCjit(any(), any())).thenReturn(Result.success(true)) - whenever(lightningRepo.stop()).thenReturn(Result.success(Unit)) - } - - private fun stubStartFiring(event: Event) { - whenever { - lightningRepo.start(any(), anyOrNull(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), any()) - }.doSuspendableAnswer { - val handler = it.getArgument(5) - handler?.invoke(event) - Result.success(Unit) - } - } - - private fun findNotificationByTitle(title: String): Notification? { - val shadows = Shadows.shadowOf(context.notificationManager) - return shadows.allNotifications.find { it.extras.getString(Notification.EXTRA_TITLE) == title } - } -} diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a06dcd724b..22d8c5b51a 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1,11 +1,8 @@ package to.bitkit.repositories import app.cash.turbine.test -import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.FeeRates -import com.synonym.bitkitcore.IBtInfo -import com.synonym.bitkitcore.ILspNode import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.LnurlException import com.synonym.bitkitcore.LnurlPayData @@ -82,7 +79,6 @@ import to.bitkit.services.ElectrumProbeError import to.bitkit.services.ElectrumProbeService import to.bitkit.services.LightningService import to.bitkit.services.LnurlService -import to.bitkit.services.LspNotificationsService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest @@ -122,8 +118,6 @@ class LightningRepoTest : BaseUnitTest() { private val lightningService = mock() private val settingsStore = mock() private val coreService = mock() - private val lspNotificationsService = mock() - private val firebaseMessaging = mock() private val keychain = mock() private val cacheStore = mock() private val preActivityMetadataRepo = mock() @@ -141,7 +135,7 @@ class LightningRepoTest : BaseUnitTest() { @Before fun setUp() = runBlocking { - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever { electrumProbeService.probe(any(), any()) }.thenReturn(Result.success(Unit)) @@ -155,8 +149,6 @@ class LightningRepoTest : BaseUnitTest() { lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, - lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, @@ -626,27 +618,24 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `start restores the initial state when cancelled during node setup`() = test { + fun `start updates geo block state before the node is up`() = test { stubNodeStatus { false } - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) - .doSuspendableAnswer { awaitCancellation() } - var result: Result? = null + whenever(lightningService.node).thenReturn(mock()) + whenever(lightningService.start(anyOrNull(), any())).doSuspendableAnswer { awaitCancellation() } + whenever(coreService.isGeoBlocked()).thenReturn(true) - val job = launch { result = sut.start(shouldRetry = false) } + val job = launch { sut.start(shouldRetry = false) } runCurrent() - job.cancelAndJoin() - testScheduler.advanceUntilIdle() - assertNull(result) - assertEquals(NodeLifecycleState.Initializing, sut.lightningState.value.nodeLifecycleState) - verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } + assertTrue(sut.lightningState.value.isGeoBlocked) + job.cancelAndJoin() } @Test - fun `start restores the initial state when cancelled while trusted peers are fetched`() = test { + fun `start restores the initial state when cancelled during node setup`() = test { stubNodeStatus { false } - val blocktank = coreService.blocktank - whenever(blocktank.info(any())).doSuspendableAnswer { awaitCancellation() } + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())) + .doSuspendableAnswer { awaitCancellation() } var result: Result? = null val job = launch { result = sut.start(shouldRetry = false) } @@ -656,14 +645,9 @@ class LightningRepoTest : BaseUnitTest() { assertNull(result) assertEquals(NodeLifecycleState.Initializing, sut.lightningState.value.nodeLifecycleState) - // The cancellation must unwind out of the trusted peers fetch instead of starting a node - verifyBlocking(lightningService, never()) { - setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) - } verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } } - // Regression #845: a node started by another path between the status check and start must not latch an error @Test fun `start adopts the running node when start throws AlreadyRunning`() = test { var nodeRunning = false @@ -979,7 +963,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) verifyBlocking(lightningService, times(2)) { - setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), eq(migration)) + setup(any(), anyOrNull(), anyOrNull(), eq(migration)) } assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -1042,7 +1026,7 @@ class LightningRepoTest : BaseUnitTest() { val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() - inOrder.verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) } @@ -1369,7 +1353,7 @@ class LightningRepoTest : BaseUnitTest() { sut.stop() whenever(lightningService.nodeId).thenReturn(null) whenever(lightningService.node).thenReturn(null) - whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull())) .thenThrow(IllegalStateException("electrum server unreachable")) assertTrue(sut.start(shouldRetry = false).isFailure) @@ -1634,18 +1618,17 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `connectToTrustedPeers should fail when node is not running`() = test { - val result = sut.connectToTrustedPeers() + fun `disconnectIdlePeers should fail when node is not running`() = test { + val result = sut.disconnectIdlePeers() assertTrue(result.isFailure) } @Test - fun `connectToTrustedPeers should succeed when node is running`() = test { + fun `start disconnects idle peers instead of connecting to fixed peers`() = test { startNodeForTesting() - whenever(lightningService.connectToTrustedPeers()).thenReturn(Unit) - val result = sut.connectToTrustedPeers() - assertTrue(result.isSuccess) + verifyBlocking(lightningService) { disconnectIdlePeers() } + verifyBlocking(lightningService, never()) { connectPeer(any()) } } @Test @@ -1956,8 +1939,6 @@ class LightningRepoTest : BaseUnitTest() { lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, - lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, @@ -2386,8 +2367,8 @@ class LightningRepoTest : BaseUnitTest() { val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, - coreService = coreService, lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + coreService = coreService, + keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, @@ -2456,8 +2437,8 @@ class LightningRepoTest : BaseUnitTest() { val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, - coreService = coreService, lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + coreService = coreService, + keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, @@ -2537,8 +2518,8 @@ class LightningRepoTest : BaseUnitTest() { }.whenever(store).broadcastPreparedCandidate(any(), any(), any(), any()) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, - coreService = coreService, lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + coreService = coreService, + keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, @@ -2746,12 +2727,6 @@ class LightningRepoTest : BaseUnitTest() { ) } - @Test - fun `registerForNotifications should fail when node is not running`() = test { - val result = sut.registerForNotifications() - assertTrue(result.isFailure) - } - @Test fun `restartWithElectrumServer should setup with new server`() = test { startNodeForTesting() @@ -2765,7 +2740,7 @@ class LightningRepoTest : BaseUnitTest() { val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() inOrder.verify(lightningService) - .setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) + .setup(any(), eq(customServerUrl), anyOrNull(), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -2806,12 +2781,12 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) // The switch to the new server fails. - whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull())) .thenThrow(RuntimeException("start failed")) // The background recovery (previous config) blocks until released. val recoveryStarted = CompletableDeferred() val releaseRecovery = CompletableDeferred() - whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull()) } .doSuspendableAnswer { recoveryStarted.complete(Unit) releaseRecovery.await() @@ -2842,7 +2817,7 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(probeError, result.exceptionOrNull()) verify(lightningService, never()).stop() verify(lightningService, never()) - .setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull()) + .setup(any(), eq(badUrl), anyOrNull(), anyOrNull()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -2859,7 +2834,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is NodeConfigNotAppliedError) verify(lightningService, never()) - .setup(any(), eq(newServerUrl), anyOrNull(), anyOrNull(), anyOrNull()) + .setup(any(), eq(newServerUrl), anyOrNull(), anyOrNull()) } // Two-request barrier: a change issued while the background recovery of a previous failed @@ -2874,12 +2849,12 @@ class LightningRepoTest : BaseUnitTest() { val nextUrl = "ssl://next.example.com:50002" whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) - whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull(), anyOrNull())) + whenever(lightningService.setup(any(), eq(badUrl), anyOrNull(), anyOrNull())) .thenThrow(RuntimeException("start failed")) // Hold the background recovery mid-flight so the second change is issued while it runs. val recoveryStarted = CompletableDeferred() val releaseRecovery = CompletableDeferred() - whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), isNull(), isNull(), anyOrNull()) } .doSuspendableAnswer { recoveryStarted.complete(Unit) releaseRecovery.await() @@ -2897,7 +2872,7 @@ class LightningRepoTest : BaseUnitTest() { // Success must mean the node was actually rebuilt with the requested server. assertTrue(result.isSuccess) - verify(lightningService).setup(any(), eq(nextUrl), anyOrNull(), anyOrNull(), anyOrNull()) + verify(lightningService).setup(any(), eq(nextUrl), anyOrNull(), anyOrNull()) } // Regression: the caller is a route-scoped ViewModel, so leaving the screen mid-change cancels @@ -2911,7 +2886,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.stop()).thenReturn(Unit) // Hold the rebuild open so the caller can be cancelled while it is in flight. val rebuildStarted = CompletableDeferred() - whenever { lightningService.setup(any(), eq(newUrl), anyOrNull(), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), eq(newUrl), anyOrNull(), anyOrNull()) } .doSuspendableAnswer { rebuildStarted.complete(Unit) awaitCancellation() @@ -2932,7 +2907,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(rethrown) // structured concurrency preserved // The detached recovery rebuilt with the previous config and the node is back up. - verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) + verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) // A cancelled change must never persist the server it failed to apply. verifyBlocking(settingsStore, never()) { update(any()) } @@ -2945,7 +2920,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) val rebuildStarted = CompletableDeferred() - whenever { lightningService.setup(any(), anyOrNull(), eq(newRgsUrl), anyOrNull(), anyOrNull()) } + whenever { lightningService.setup(any(), anyOrNull(), eq(newRgsUrl), anyOrNull()) } .doSuspendableAnswer { rebuildStarted.complete(Unit) awaitCancellation() @@ -2965,7 +2940,7 @@ class LightningRepoTest : BaseUnitTest() { testScheduler.advanceUntilIdle() assertTrue(rethrown) - verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull(), anyOrNull()) + verify(lightningService).setup(any(), isNull(), isNull(), anyOrNull()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) verifyBlocking(settingsStore, never()) { update(any()) } } @@ -2982,7 +2957,7 @@ class LightningRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) val inOrder = inOrder(lightningService) inOrder.verify(lightningService).stop() - inOrder.verify(lightningService).setup(any(), isNull(), eq(customRgsUrl), anyOrNull(), anyOrNull()) + inOrder.verify(lightningService).setup(any(), isNull(), eq(customRgsUrl), anyOrNull()) inOrder.verify(lightningService).start(anyOrNull(), any()) assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) } @@ -3003,7 +2978,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.node).thenReturn(null) whenever(lightningService.stop()).thenReturn(Unit) whenever( - lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull(), anyOrNull()), + lightningService.setup(any(), isNull(), eq("https://bad.rgs/snapshot"), anyOrNull()), ).thenThrow(RuntimeException("Failed to start node")) val result = sut.restartWithRgsServer("https://bad.rgs/snapshot") @@ -3019,8 +2994,6 @@ class LightningRepoTest : BaseUnitTest() { lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, - lspNotificationsService = lspNotificationsService, - firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, cacheStore = cacheStore, @@ -3178,63 +3151,6 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(serviceError, result.exceptionOrNull()) } - @Test - fun `start should load trusted peers from blocktank info`() = test { - sut.setInitNodeLifecycleState() - whenever(lightningService.node).thenReturn(null) - - val blocktank = mock() - whenever(coreService.blocktank).thenReturn(blocktank) - - val mockNodes = listOf( - ILspNode( - alias = "LSP1", - pubkey = "node1pubkey", - connectionStrings = listOf("node1pubkey@node1.example.com:9735"), - readonly = null, - ), - ILspNode( - alias = "LSP2", - pubkey = "node2pubkey", - connectionStrings = listOf("node2pubkey@node2.example.com:9735"), - readonly = null, - ), - ) - val mockInfo = mock { on { nodes } doReturn mockNodes } - whenever(blocktank.info(refresh = false)).thenReturn(mockInfo) - - val result = sut.start() - - assertTrue(result.isSuccess) - verify(lightningService).setup( - any(), - anyOrNull(), - anyOrNull(), - argThat { peers -> - peers?.size == 2 && - peers.any { it.nodeId == "node1pubkey" && it.address == "node1.example.com:9735" } && - peers.any { it.nodeId == "node2pubkey" && it.address == "node2.example.com:9735" } - }, - anyOrNull(), - ) - } - - @Test - fun `start should pass null trusted peers when blocktank returns null`() = test { - sut.setInitNodeLifecycleState() - whenever(lightningService.node).thenReturn(null) - - val blocktank = mock() - whenever(coreService.blocktank).thenReturn(blocktank) - whenever(blocktank.info(refresh = false)).thenReturn(null) - whenever(blocktank.info(refresh = true)).thenReturn(null) - - val result = sut.start() - - assertTrue(result.isSuccess) - verify(lightningService).setup(any(), anyOrNull(), anyOrNull(), isNull(), anyOrNull()) - } - @Test fun `getBalanceForAddressType should succeed when node is running`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index f4d63781f9..d87dfb2895 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -879,6 +879,41 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertEquals(unknown, store.current()) } + @Test + fun `refused Blocktank funding can be released once its order can no longer be retried`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admit( + walletId = WalletScope.default, requestId = null, orderId = "order-1", address = "bcrt1qfunding", + amountSats = 50_000uL, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = null, tags = emptyList(), + transferContext = OnchainTransferContext(txTotalSats = 50_200uL, preTransferOnchainSats = 100_000uL), + beforeSendAttempt = {}, + ) + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), + listOf(OnchainSendInput("11".repeat(32), 0u)), + attempt.address, + attempt.amountSats, + miningFeeSats = 200uL, + ) + store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + val outcome = store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { beforeDispatch -> + beforeDispatch() + OnchainSendOutcome.Rejected(receipt.txid, "min relay fee not met") + } + store.recordOutcome(attempt.attemptId, outcome, 0) + val refused = requireNotNull(store.current()) + + // Retry original is refused while transfers are unavailable, so release is the only exit. + assertTrue(refused.blocksNextSend) + assertTrue(refused.canBeAbandoned, "A refused Blocktank funding attempt must be releasable") + } + private suspend fun OnchainSendAttemptStore.admitForTest( walletId: String = "wallet-1", contactPublicKey: String? = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index b9a8dd614c..8e2788cd2f 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -3148,6 +3148,24 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(paymentProofRepo().onchainRequestHasEnded(attempt)) } + @Test + fun `refused Blocktank funding is released once transfers are unavailable and keeps Shop proofs`() = test { + val attempt = unknownShopAttempt("ab".repeat(32)) + .copy(requestId = null, payerIdentity = null, isTransfer = true, orderId = "order-1") + val shopProof = startedOnchainProof(unknownShopAttempt("cd".repeat(32))) + storedProofs = listOf(shopProof) + stubAbandon(attempt) + + whenever(lightningRepo.isTransferBlocked()).thenReturn(false) + assertFalse(paymentProofRepo().onchainRequestHasEnded(attempt)) + + whenever(lightningRepo.isTransferBlocked()).thenReturn(true) + val wallet = FakeReleaseWallet() + assertTrue(paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt, wallet) {}.isSuccess) + assertEquals(1, wallet.broadcasts) + assertEquals(listOf(shopProof), storedProofs) + } + @Test fun `recurring period counts as ended only without its own proof`() = test { val periodStart = "2027-01-01T00:00:00Z" diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 1e183ef287..524078c500 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -21,10 +21,15 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import org.lightningdevkit.ldknode.AddressInfo import org.lightningdevkit.ldknode.AddressType +import org.lightningdevkit.ldknode.AnchorChannelsConfig +import org.lightningdevkit.ldknode.BalanceSource +import org.lightningdevkit.ldknode.Config import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind +import org.lightningdevkit.ldknode.LightningBalance +import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -36,6 +41,8 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus +import org.lightningdevkit.ldknode.PeerDetails +import org.lightningdevkit.ldknode.PendingSweepBalance import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any @@ -673,6 +680,73 @@ class LightningServiceTest : BaseUnitTest() { assertEquals(listOf(enabled.xpub, authorizing.xpub), configs.map { it.xpub }) } + @Test + fun `node config trusts no peer for zero-conf or force-close broadcasts`() { + val lspNodeIds = listOf("lspNodeId") + val base = Config( + storageDirPath = "", + network = Network.REGTEST, + listeningAddresses = null, + announcementAddresses = null, + nodeAlias = null, + trustedPeers0conf = lspNodeIds, + probingLiquidityLimitMultiplier = 3uL, + anchorChannelsConfig = AnchorChannelsConfig( + trustedPeersNoReserve = lspNodeIds, + perChannelReserveSats = 25_000uL + ), + routeParameters = null, + scoringFeeParams = null, + scoringDecayParams = null, + includeUntrustedPendingInSpendable = false, + addressType = AddressType.NATIVE_SEGWIT, + addressTypesToMonitor = emptyList(), + onchainWalletAccounts = emptyList(), + ) + + val config = nodeConfig(base = base, storageDirPath = "ldk", onchainWalletAccounts = emptyList()) + + assertTrue(config.trustedPeers0conf.isEmpty()) + assertEquals(emptyList(), config.anchorChannelsConfig?.trustedPeersNoReserve) + assertEquals(1uL, config.anchorChannelsConfig?.perChannelReserveSats) + } + + @Test + fun `idle peers are stored peers with no channel and no claimable balance`() { + val peers = listOf( + peer("idle"), + peer("withChannel"), + peer("withClaim"), + peer("notStored", isPersisted = false), + ) + val channels = listOf(createChannelDetails().copy(counterpartyNodeId = "withChannel")) + val claims = listOf( + LightningBalance.ClaimableAwaitingConfirmations( + channelId = "closed", + counterpartyNodeId = "withClaim", + amountSatoshis = 1_000uL, + confirmationHeight = 100u, + source = BalanceSource.HOLDER_FORCE_CLOSED, + ), + ) + + val idle = idlePeerIds(peers, channels, claims, pendingSweeps = emptyList()) + + assertEquals(listOf("idle"), idle) + } + + @Test + fun `no peer is idle while a closed channel awaits its sweep`() { + val sweeps = listOf(PendingSweepBalance.PendingBroadcast(channelId = "closed", amountSatoshis = 1_000uL)) + + val idle = idlePeerIds(listOf(peer("idle")), emptyList(), emptyList(), sweeps) + + assertTrue(idle.isEmpty()) + } + + private fun peer(nodeId: String, isPersisted: Boolean = true) = + PeerDetails(nodeId = nodeId, address = "127.0.0.1:9735", isPersisted = isPersisted, isConnected = true) + @Test fun `reconciliation pre-reveals index 999 for an already tracked authorizing account`() = test { val account = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index e4f63a19e7..4f9e17f804 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -476,6 +476,29 @@ class SendPendingViewModelTest : BaseUnitTest() { assertEquals(original, sut.uiState.value.abandonableAttempt) } + @Test + fun `refused Blocktank funding offers cancel after one check while transfers are unavailable`() = test { + val original = pendingOriginal().copy(isTransfer = true, orderId = "order") + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + whenever(paykitPaymentProofRepo.onchainRequestHasEnded(original)).thenReturn(true) + sut.initOnchain(original.txid, amount) + runCurrent() + assertEquals(original, sut.uiState.value.abandonableAttempt) + } + + @Test + fun `retryable Blocktank funding is checked once and not polled`() = test { + val original = pendingOriginal().copy(isTransfer = true, orderId = "order") + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + whenever(paykitPaymentProofRepo.onchainRequestHasEnded(original)).thenReturn(false) + sut.initOnchain(original.txid, amount) + runCurrent() + advanceTimeBy(61.seconds) + runCurrent() + assertNull(sut.uiState.value.abandonableAttempt) + org.mockito.kotlin.verify(paykitPaymentProofRepo, org.mockito.kotlin.times(1)).onchainRequestHasEnded(original) + } + @Test fun `a second cancel uses the attempt the first one saved`() = test { val original = pendingOriginal().copy( diff --git a/app/src/test/java/to/bitkit/utils/CryptoTest.kt b/app/src/test/java/to/bitkit/utils/CryptoTest.kt index f1e9ff416a..aae64886f8 100644 --- a/app/src/test/java/to/bitkit/utils/CryptoTest.kt +++ b/app/src/test/java/to/bitkit/utils/CryptoTest.kt @@ -4,12 +4,10 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider import org.junit.After import org.junit.Before import org.junit.Test -import to.bitkit.env.Env.derivationName import to.bitkit.ext.fromBase64 import to.bitkit.ext.fromHex import to.bitkit.ext.toBase64 import to.bitkit.ext.toHex -import to.bitkit.fcm.EncryptedNotification import java.security.Provider import java.security.Security import kotlin.test.assertContentEquals @@ -57,7 +55,7 @@ class CryptoTest { val crypto = Crypto() val keys = crypto.generateKeyPair() assertContentEquals(keys.publicKey, crypto.getPublicKey(keys.privateKey)) - val secret = crypto.generateSharedSecret(keys.privateKey, keys.publicKey.toHex(), derivationName) + val secret = crypto.generateSharedSecret(keys.privateKey, keys.publicKey.toHex(), DERIVATION_NAME) val plaintext = "Provider preservation".toByteArray() val encrypted = crypto.encrypt(plaintext, secret) assertContentEquals(plaintext, crypto.decrypt(encrypted, secret)) @@ -81,14 +79,12 @@ class CryptoTest { val sharedSecret = sut.generateSharedSecret(privateKey, publicKey.toHex()) assertEquals(33, sharedSecret.size) - val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), derivationName) + val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), DERIVATION_NAME) assertEquals(32, sharedSecretHash.size) } @Test fun `it should decrypt payload it encrypted`() { - val derivationName = derivationName - // Step 1: Client generates a key pair val clientKeys = sut.generateKeyPair() val clientPublicKey = clientKeys.publicKey @@ -100,14 +96,14 @@ class CryptoTest { val serverPrivateKey = serverKeys.privateKey // Step 3: Server generates shared secret using its private key and client public key - val serverSecret = sut.generateSharedSecret(serverPrivateKey, clientPublicKey.toHex(), derivationName) + val serverSecret = sut.generateSharedSecret(serverPrivateKey, clientPublicKey.toHex(), DERIVATION_NAME) // Step 4: Server encrypts data using the shared secret val dataToEncrypt = "Hello from the server!" val encrypted = sut.encrypt(dataToEncrypt.toByteArray(), serverSecret) assertEquals(12, encrypted.iv.size) assertEquals(16, encrypted.tag.size) - val response = EncryptedNotification( + val response = EncryptedResponse( cipher = encrypted.cipher.toBase64(), iv = encrypted.iv.toHex(), tag = encrypted.tag.toHex(), @@ -115,7 +111,7 @@ class CryptoTest { ) // Step 5: Client generates its shared secret using its private key and server public key - val clientSecret = sut.generateSharedSecret(clientPrivateKey, response.publicKey, derivationName) + val clientSecret = sut.generateSharedSecret(clientPrivateKey, response.publicKey, DERIVATION_NAME) // Step 6: Client decrypts the payload using the shared secret val decrypted = sut.decrypt( @@ -137,7 +133,6 @@ class CryptoTest { fun testBlocktankEncryptedPayload() { val clientPrivateKey = "cc74b1a4fdcd35916c766d3318c5a93b7e33a36ebeff0463128bf284975c2680" val serverPublicKey = "031e9923e689a181a803486b7d8c0d4a5aad360edb70c8bb413a98458d91652213" - val derivationName = "bitkit-notifications" @Suppress("MaxLineLength") val ciphertext = ("l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSIJe/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=").fromBase64() @@ -152,7 +147,7 @@ class CryptoTest { val sharedSecretOnServer = "028ce542975d6d7b2307c92e527d507b03ffb3d897eb2e0830d29f40d5efd80ee3".fromHex() assertEquals(sharedSecretOnServer.toHex(), sharedSecret.toHex()) - val sharedHash = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey, derivationName) + val sharedHash = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey, DERIVATION_NAME) val sharedHashOnServer = "3a9d552cb16dfae40feae644254c4ca46cab82e570de5662aacc4018e33b609b".fromHex() assertEquals(sharedHashOnServer.toHex(), sharedHash.toHex()) @@ -215,3 +210,12 @@ class CryptoTest { private class OutdatedBcProvider : Provider(BC, 1.0, "Stub for the BC provider that Android registers") } + +private const val DERIVATION_NAME = "bitkit-notifications" + +private data class EncryptedResponse( + val cipher: String, + val iv: String, + val tag: String, + val publicKey: String, +) diff --git a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt index a62bcba3eb..114cee5ec4 100644 --- a/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt +++ b/app/src/test/java/to/bitkit/utils/timedsheets/sheets/NotificationsTimedSheetTest.kt @@ -13,14 +13,14 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore -import to.bitkit.models.BalanceState -import to.bitkit.repositories.WalletRepo +import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitSubscription import to.bitkit.test.BaseUnitTest import to.bitkit.ui.components.TimedSheetType class NotificationsTimedSheetTest : BaseUnitTest() { private lateinit var settingsStore: SettingsStore - private lateinit var walletRepo: WalletRepo + private lateinit var paykitPaymentRequestRepo: PaykitPaymentRequestRepo private lateinit var sut: NotificationsTimedSheet private val defaultSettings = SettingsData() @@ -29,7 +29,7 @@ class NotificationsTimedSheetTest : BaseUnitTest() { @Before fun setUp() = runBlocking { settingsStore = mock() - walletRepo = mock() + paykitPaymentRequestRepo = mock() whenever(settingsStore.data).thenReturn(settingsFlow) whenever(settingsStore.update(any())).then { @@ -38,11 +38,11 @@ class NotificationsTimedSheetTest : BaseUnitTest() { flowOf(settingsFlow.value) } - whenever(walletRepo.balanceState).thenReturn( - MutableStateFlow(BalanceState(totalLightningSats = 1000UL)) + whenever(paykitPaymentRequestRepo.subscriptions).thenReturn( + MutableStateFlow(listOf(mock())) ) - sut = NotificationsTimedSheet(settingsStore, walletRepo) + sut = NotificationsTimedSheet(settingsStore, paykitPaymentRequestRepo) } @Test @@ -65,9 +65,9 @@ class NotificationsTimedSheetTest : BaseUnitTest() { } @Test - fun `should not show when no lightning balance`() = test { - whenever(walletRepo.balanceState).thenReturn( - MutableStateFlow(BalanceState(totalLightningSats = 0UL)) + fun `should not show without subscriptions`() = test { + whenever(paykitPaymentRequestRepo.subscriptions).thenReturn( + MutableStateFlow(emptyList()) ) val result = sut.shouldShow() @@ -89,7 +89,7 @@ class NotificationsTimedSheetTest : BaseUnitTest() { } @Test - fun `should show after one week timeout with lightning balance`() = test { + fun `should show after one week timeout with subscriptions`() = test { val eightDaysAgo = System.currentTimeMillis() - (8L * 24 * 60 * 60 * 1000) settingsFlow.value = defaultSettings.copy( notificationsGranted = false, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b21e8ee307..c816598e0c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -12980,6 +12980,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { OnchainSendOutcome.Accepted("ef".repeat(32)) } } + whenever(lightningRepo.isTransferBlocked()).thenReturn(false) val invalidOrders = listOf( order.copy(state2 = com.synonym.bitkitcore.BtOrderState2.PAID), order.copy(orderExpiresAt = (Clock.System.now() - 1.seconds).toString()), @@ -12996,6 +12997,40 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(1, dispatched) } + @Test + fun `recovery of a Blocktank order broadcasts nothing while transfers are unavailable`() = test { + val order = to.bitkit.ext.mockOrder().copy( + orderExpiresAt = (Clock.System.now() + 60.seconds).toString(), + ) + val original = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "original-order-attempt", + transferContext = to.bitkit.repositories.OnchainTransferContext( + 1_200uL, + 100_000uL, + order.clientBalanceSat, + order.feeSat, + ), + requestId = null, + orderId = order.id, + address = requireNotNull(order.payment?.onchain?.address), + amountSats = order.feeSat, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = true, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, + ) + whenever(lightningRepo.isTransferBlocked()).thenReturn(true) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + + assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isFailure) + + verify(lightningRepo, never()).retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any(), any()) + assertEquals(Sheet.TransferUnavailable, sut.currentSheet.value) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 991be6cf8b..f915475380 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -168,6 +168,7 @@ class TransferViewModelTest : BaseUnitTest() { } .thenReturn(Result.success("transfer-id")) whenever { hwWalletRepo.needsPassphrase(any()) }.thenReturn(false) + whenever { lightningRepo.isTransferBlocked() }.thenReturn(false) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) @@ -1200,6 +1201,48 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) } + @Test + fun `onTransferToSpendingConfirm pays no held order when transfers are unavailable`() = test { + val order = spendingOrder(feeSat = 99_000uL) + stubSpendableBalances(spendable = 200_000u) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(listOf(stubUtxo(200_000u)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(500uL)) + stubSendOnChain() + val effects = mutableListOf() + backgroundScope.launch { sut.transferEffects.collect { effects += it } } + quoteOrder(order) + prepareConfirm() + whenever(lightningRepo.isTransferBlocked()).thenReturn(true) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + assertTrue(TransferEffect.OnTransferUnavailable in effects) + assertEquals(false, sut.spendingUiState.value.isConfirmPaying) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = any(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + } + @Test fun `onTransferToSpendingConfirm does not drain when normal fee leaves non-dust change`() = test { val order = spendingOrder(feeSat = 35_341uL) diff --git a/changelog.d/next/1696.changed.md b/changelog.d/next/1696.changed.md new file mode 100644 index 0000000000..3020a38b33 --- /dev/null +++ b/changelog.d/next/1696.changed.md @@ -0,0 +1 @@ +Transfers to spending now point to Manual Setup, since Bitkit no longer opens Lightning connections automatically, and a forced close of a Blocktank connection no longer leaves funds stuck. diff --git a/docs/timed-sheets.md b/docs/timed-sheets.md index 9a7995eb27..124f8d9c60 100644 --- a/docs/timed-sheets.md +++ b/docs/timed-sheets.md @@ -7,7 +7,7 @@ The manager checks sheets two seconds after the home screen resumes and cancels 1. App update 2. USDT 3. Backup -4. Background payments +4. Notifications 5. QuickPay 6. High balance @@ -55,15 +55,15 @@ Backup uses a snooze model instead of a permanent seen flag: After the one-day ask interval passes, the Backup timed sheet can appear again if the backup is still unverified and the wallet still has a balance. -## Background Payments +## Notifications -The Background Payments timed sheet is eligible when notification permission has not been granted, the wallet has a Lightning balance, and `notificationsIgnoredMillis` is older than one week. +The Notifications timed sheet is eligible when notification permission has not been granted, the wallet has at least one Paykit subscription, and `notificationsIgnoredMillis` is older than one week. -- `Later` marks the background-payments intro as seen and dismisses the sheet. Dismissal records `notificationsIgnoredMillis`. +- `Later` marks the notifications intro as seen and dismisses the sheet. Dismissal records `notificationsIgnoredMillis`. - `Enable` marks the intro as seen, dismisses the sheet, records `notificationsIgnoredMillis`, and requests notification permission. - A generic timed sheet dismissal records `notificationsIgnoredMillis` without marking the intro as seen. -After the one-week ask interval passes, the timed sheet can appear again if notification permission is still not granted and the wallet still has a Lightning balance. The full-screen settings intro is a separate route with only an enable action. +After the one-week ask interval passes, the timed sheet can appear again if notification permission is still not granted and the wallet still has a Paykit subscription. The full-screen settings intro is a separate route with only an enable action. ## High Balance diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml new file mode 100644 index 0000000000..1e9b604ed9 --- /dev/null +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -0,0 +1,27 @@ + + + Verifies that Bitkit points users to Manual Setup now that Blocktank no longer sells Lightning channels. + Blocktank's geocheck answers 403, so the app is geo-blocked, and the Transfer To Spending buttons on the + wallet screens open the "Transfer Unavailable" sheet (testTag "TransferUnavailable") instead of the transfer flow. The + sheet shows "MANUAL SETUP REQUIRED" (testTag "TransferUnavailableTitle") and the buttons "Learn More" + (testTag "TransferUnavailableLearnMore"), which opens https://blog.bitkit.to/ in the browser, and "Continue" + (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning + path shows the same sheet content. + + Precondition: dev build with geoblocking on (the default; `GEO=false` turns it off), onboarded wallet with + a positive on-chain Savings balance and no channels. Start on the wallet home screen. + + + Tap the Savings balance card (testTag "ActivitySavings") and verify "Transfer To Spending" (testTag "TransferToSpending") is shown + Tap "Transfer To Spending" and verify the sheet titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." + Tap "Learn More" and verify the browser opens blog.bitkit.to, then return to Bitkit + Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen shows the node id (testTag "NodeIdInput"), host (testTag "HostInput") and port (testTag "PortInput") fields + Go back to the wallet home, tap the Spending balance card (testTag "ActivitySpending"), tap the transfer button (testTag "TransferFromSavings") and verify the same sheet opens; close it + Open Settings › Advanced › Lightning Connections, tap add connection (testTag "add_connection_button") and verify the screen text reads "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider." + Verify "Manual Setup" (testTag "FundManual") is the first option and enabled, and "Transfer from Savings" (testTag "FundTransfer"), "Transfer from HW wallet" (testTag "FundHardware") and "Use Other Wallet" (testTag "FundReceive") below it are disabled; tap "Transfer from Savings" and verify nothing opens + Tap "Learn More" (testTag "FundLearnMore") at the bottom and verify the browser opens blog.bitkit.to, then return to Bitkit and go back to the wallet home + Tap "Receive" (testTag "Receive"), select the Spending tab (testTag "Tab-spending") and tap "Receive Lightning funds" + Verify the screen titled "Transfer Unavailable" shows "MANUAL SETUP REQUIRED" and "Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider.", with a back button + Tap "Continue" and verify the sheet closes and the "MANUAL SETUP" screen opens + + From 2ba664ed1cd5f177edfc5c0663f0ea2c086530be Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 18:32:39 +0100 Subject: [PATCH 2/6] feat: blocktank sunset --- .github/workflows/e2e-staging.yml | 1 - .github/workflows/e2e.yml | 1 - .github/workflows/e2e_migration.yml | 1 - AGENTS.md | 5 +- Justfile | 4 +- README.md | 8 - app/build.gradle.kts | 2 - .../java/to/bitkit/services/BlocktankTest.kt | 140 - .../ui/screens/transfer/FundingScreenTest.kt | 116 +- .../receive/ReceiveAutoTabSelectionTest.kt | 1 - .../receive/ReceiveHardwareFlowTest.kt | 1 - .../wallets/receive/ReceiveNavigationTest.kt | 8 +- app/src/main/java/to/bitkit/env/Env.kt | 2 - .../to/bitkit/models/CjitQuoteValidator.kt | 22 - .../bitkit/models/ReceiveLiquidityDecision.kt | 34 +- .../to/bitkit/repositories/BlocktankRepo.kt | 505 --- .../to/bitkit/repositories/LightningRepo.kt | 24 +- .../java/to/bitkit/repositories/LogsRepo.kt | 2 - .../repositories/PaykitPaymentProofRepo.kt | 7 +- .../java/to/bitkit/repositories/WalletRepo.kt | 2 - .../java/to/bitkit/services/CoreService.kt | 92 - app/src/main/java/to/bitkit/ui/ContentView.kt | 259 +- .../main/java/to/bitkit/ui/MainActivity.kt | 2 - .../java/to/bitkit/ui/components/SheetHost.kt | 1 - .../ui/onboarding/OnboardingSlidesScreen.kt | 25 - .../screens/transfer/FundingAdvancedScreen.kt | 81 - .../ui/screens/transfer/FundingScreen.kt | 192 +- .../ui/screens/transfer/LiquidityScreen.kt | 115 - .../ui/screens/transfer/SettingUpScreen.kt | 222 - .../transfer/SpendingAdvancedScreen.kt | 338 -- .../screens/transfer/SpendingAmountScreen.kt | 309 -- .../screens/transfer/SpendingConfirmScreen.kt | 392 -- .../screens/transfer/SpendingIntroScreen.kt | 80 - .../screens/transfer/TransferIntroScreen.kt | 85 - .../hardware/SpendingAmountHwScreen.kt | 293 -- .../transfer/hardware/SpendingHwSignScreen.kt | 294 -- .../hardware/SpendingHwSignedScreen.kt | 109 - .../bitkit/ui/screens/wallets/HomeScreen.kt | 21 +- .../wallets/receive/EditInvoiceScreen.kt | 43 +- .../screens/wallets/receive/EditInvoiceVM.kt | 27 - .../wallets/receive/ReceiveAmountScreen.kt | 290 -- .../receive/ReceiveCjitErrorPresenter.kt | 33 - .../wallets/receive/ReceiveConfirmScreen.kt | 308 -- .../wallets/receive/ReceiveInvoiceUtils.kt | 12 +- .../wallets/receive/ReceiveLiquidityScreen.kt | 221 - .../wallets/receive/ReceiveQrScreen.kt | 51 +- .../screens/wallets/receive/ReceiveSheet.kt | 179 +- ...Screen.kt => TransferUnavailableScreen.kt} | 16 +- .../java/to/bitkit/ui/sheets/GiftLoading.kt | 149 - .../java/to/bitkit/ui/sheets/GiftSheet.kt | 97 - .../java/to/bitkit/ui/sheets/GiftViewModel.kt | 155 - app/src/main/java/to/bitkit/utils/Errors.kt | 7 - .../java/to/bitkit/viewmodels/AppViewModel.kt | 43 +- .../bitkit/viewmodels/BlocktankViewModel.kt | 17 - .../to/bitkit/viewmodels/SettingsViewModel.kt | 18 - .../to/bitkit/viewmodels/TransferViewModel.kt | 1717 +------- app/src/main/res/values-ar/strings.xml | 74 +- app/src/main/res/values-b+es+419/strings.xml | 74 +- app/src/main/res/values-ca/strings.xml | 74 +- app/src/main/res/values-cs/strings.xml | 74 +- app/src/main/res/values-de/strings.xml | 76 +- app/src/main/res/values-el/strings.xml | 74 +- app/src/main/res/values-es-rES/strings.xml | 74 +- app/src/main/res/values-es/strings.xml | 76 +- app/src/main/res/values-fr/strings.xml | 76 +- app/src/main/res/values-it/strings.xml | 74 +- app/src/main/res/values-nl/strings.xml | 74 +- app/src/main/res/values-pl/strings.xml | 76 +- app/src/main/res/values-pt-rBR/strings.xml | 74 +- app/src/main/res/values-pt/strings.xml | 76 +- app/src/main/res/values-ru/strings.xml | 86 +- app/src/main/res/values/strings.xml | 72 - .../bitkit/build/BuildOutputContractTest.kt | 1 - .../to/bitkit/build/FeesChangedStringsTest.kt | 27 - .../bitkit/models/CjitQuoteValidatorTest.kt | 52 - .../models/ReceiveLiquidityDecisionTest.kt | 94 +- .../bitkit/repositories/BlocktankRepoTest.kt | 381 -- .../bitkit/repositories/BtOrderFixtures.kt} | 13 +- .../bitkit/repositories/LightningRepoTest.kt | 17 +- .../PaykitPaymentProofRepoTest.kt | 6 +- .../bitkit/repositories/TransferRepoTest.kt | 1 - .../to/bitkit/repositories/WalletRepoTest.kt | 37 +- .../test/java/to/bitkit/ui/ContentViewTest.kt | 45 +- .../subscriptions/AllowancesViewModelTest.kt | 4 +- .../wallets/receive/CjitEntryDetailsTest.kt | 49 - .../wallets/receive/EditInvoiceVMTest.kt | 41 +- .../receive/ReceiveAutoTabSwitchTest.kt | 12 - .../receive/ReceiveInvoiceEditStateTest.kt | 98 - .../receive/ReceiveInvoiceUtilsTest.kt | 89 +- .../calculator/CalculatorViewModelTest.kt | 4 +- .../viewmodels/AppViewModelSendFlowTest.kt | 87 +- .../viewmodels/TransferViewModelTest.kt | 3569 +---------------- changelog.d/next/1696.changed.md | 2 +- docs/receive-liquidity.md | 51 +- docs/screens-map.md | 18 +- journeys/README.md | 10 +- journeys/amount-limits/README.md | 16 +- .../transfer-spending-advanced-over-max.xml | 26 - .../transfer-spending-over-max.xml | 24 - .../transfer-spending-preset-delete.xml | 47 - ...restore-keeps-tags-and-closed-channels.xml | 8 +- journeys/cjit-notifications/README.md | 71 +- .../cjit-foreground-service-notification.xml | 25 - .../cjit-push-single-notification.xml | 24 - ...n-cjit-channel-no-payment-notification.xml | 7 +- journeys/hardware-wallet/README.md | 4 - .../passphrase-transfer-to-spending.xml | 74 - .../transfer-to-spending-max-lsp-cap.xml | 44 - .../transfer-to-spending-node-warmup.xml | 37 - .../hardware-wallet/transfer-to-spending.xml | 59 - journeys/notification-permission/README.md | 62 - ...ceive-cjit-confirm-notification-toggle.xml | 23 - ...ive-cjit-liquidity-notification-toggle.xml | 25 - .../toggle-off-opens-system-settings.xml | 24 - ...r-spending-confirm-notification-toggle.xml | 25 - journeys/receive/receive-tabs-wraparound.xml | 2 +- journeys/receive/usdt-receive-swipe-tabs.xml | 2 +- .../spending-confirm-amount-change.xml | 37 - .../transfer/transfer-unavailable-sheet.xml | 8 +- .../closed-channel-transfer-settles.xml | 58 - 120 files changed, 267 insertions(+), 13556 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/models/CjitQuoteValidator.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/LiquidityScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/SpendingIntroScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/TransferIntroScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrorPresenter.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt rename app/src/main/java/to/bitkit/ui/screens/wallets/receive/{LocationBlockScreen.kt => TransferUnavailableScreen.kt} (76%) delete mode 100644 app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt delete mode 100644 app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt delete mode 100644 app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt delete mode 100644 app/src/test/java/to/bitkit/build/FeesChangedStringsTest.kt delete mode 100644 app/src/test/java/to/bitkit/models/CjitQuoteValidatorTest.kt rename app/src/{main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt => test/java/to/bitkit/repositories/BtOrderFixtures.kt} (84%) delete mode 100644 app/src/test/java/to/bitkit/ui/screens/wallets/receive/CjitEntryDetailsTest.kt delete mode 100644 journeys/amount-limits/transfer-spending-advanced-over-max.xml delete mode 100644 journeys/amount-limits/transfer-spending-over-max.xml delete mode 100644 journeys/amount-limits/transfer-spending-preset-delete.xml delete mode 100644 journeys/cjit-notifications/cjit-foreground-service-notification.xml delete mode 100644 journeys/cjit-notifications/cjit-push-single-notification.xml delete mode 100644 journeys/hardware-wallet/passphrase-transfer-to-spending.xml delete mode 100644 journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml delete mode 100644 journeys/hardware-wallet/transfer-to-spending-node-warmup.xml delete mode 100644 journeys/hardware-wallet/transfer-to-spending.xml delete mode 100644 journeys/notification-permission/README.md delete mode 100644 journeys/notification-permission/receive-cjit-confirm-notification-toggle.xml delete mode 100644 journeys/notification-permission/receive-cjit-liquidity-notification-toggle.xml delete mode 100644 journeys/notification-permission/toggle-off-opens-system-settings.xml delete mode 100644 journeys/notification-permission/transfer-spending-confirm-notification-toggle.xml delete mode 100644 journeys/transfer/spending-confirm-amount-change.xml delete mode 100644 journeys/transfers/closed-channel-transfer-settles.xml diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml index 2facb6f500..441e7e23bc 100644 --- a/.github/workflows/e2e-staging.yml +++ b/.github/workflows/e2e-staging.yml @@ -59,7 +59,6 @@ jobs: CHATWOOT_API: ${{ secrets.CHATWOOT_API }} E2E: true E2E_BACKEND: network - GEO: false TREZOR_BRIDGE: true TREZOR_BRIDGE_URL: http://10.0.2.2:21325 run: ./gradlew assembleDevDebug diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8a6c402ae6..12aa57006a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -74,7 +74,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHATWOOT_API: ${{ secrets.CHATWOOT_API }} E2E: true - GEO: false run: ./gradlew assembleDevDebug - name: Rename APK diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index d6d3d59b52..8525b0e33a 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -70,7 +70,6 @@ jobs: CHATWOOT_API: ${{ secrets.CHATWOOT_API }} E2E: true E2E_BACKEND: network - GEO: false run: ./gradlew assembleDevDebug - name: Rename APK diff --git a/AGENTS.md b/AGENTS.md index adc35d7a77..9dc090c0e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,9 +199,6 @@ just test android # Build for E2E tests (UI hooks enabled, local Electrum by default) just e2e -# Build for E2E tests with geoblocking disabled -just e2e no geo - # Build for E2E tests using network Electrum (not local; staging/mainnet based on flavor) just e2e network assembleTnetRelease @@ -290,7 +287,7 @@ writing one; it has the format, the runner commands and the per-suite preconditi - KEEP the file name, `` and `` prose identical across the two repos so the specs stay diffable. Change only what the platform forces. - MATCH the iOS identifier string when adding a `testTag` a journey asserts on — the vocabulary is - deliberately shared (`N9`, `NRemove`, `SpendingAmountContinue`, `HardwareTransferSign`). Record any + deliberately shared (`N9`, `NRemove`, `TransferUnavailableContinue`, `HardwareTransferToSpending`). Record any name that cannot match in the identifier table in `journeys/README.md`. - ADAPT rather than transcribe when a platform genuinely behaves differently, and say so in the journey's `` and the suite README — never assert behaviour the platform does not have. diff --git a/Justfile b/Justfile index 47ffc68fac..900c0ca4c7 100644 --- a/Justfile +++ b/Justfile @@ -24,7 +24,7 @@ list: "lint" \ "lint baseline" \ "format" \ - "e2e [network|no geo|TASK] [TASK]" \ + "e2e [network|TASK] [TASK]" \ "changelog [all|next|hotfix]" \ "clean" @@ -191,7 +191,7 @@ format: {{ gradle }} detekt --auto-correct --rerun-tasks e2e mode="" value="" task="assembleDevRelease": - {{ if mode == "" { "E2E=true " + gradle + " " + task } else if mode == "network" { "E2E=true E2E_BACKEND=network " + gradle + " " + if value == "" { task } else { value } } else if mode == "no" { if value == "geo" { "GEO=false E2E=true " + gradle + " " + task } else { error("usage: just e2e no geo [TASK]") } } else { "E2E=true " + gradle + " " + mode } }} + {{ if mode == "" { "E2E=true " + gradle + " " + task } else if mode == "network" { "E2E=true E2E_BACKEND=network " + gradle + " " + if value == "" { task } else { value } } else { "E2E=true " + gradle + " " + mode } }} changelog target="all": ./scripts/preview-changelog.sh --target {{ target }} diff --git a/README.md b/README.md index 2bc5e0bd68..90056380a5 100644 --- a/README.md +++ b/README.md @@ -203,14 +203,6 @@ just e2e network assembleTnetRelease just e2e network assembleMainnetRelease ``` -#### Disable Geoblocking Checks - -By default, geoblocking checks via API are enabled. To disable at build time, use the `GEO` environment variable: - -```sh -just e2e no geo -``` - ## Contributing ### AI Code Review with Claude diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0140d5d77..c4759ffa76 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,7 +81,6 @@ val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL") .orElse(e2eLocalHostEnv.map { "http://$it:6288" }) val e2eHomeserverPubkyEnv = providers.environmentVariable("E2E_HOMESERVER_PUBKY").orElse("") val e2eHomegateOverrideEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("") -val geoEnv = envFlag("GEO", default = true) val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") val trezorBridgeUrlEnv = localProp("TREZOR_BRIDGE_URL").orElse("http://10.0.2.2:21325") @@ -365,7 +364,6 @@ androidComponents { buildConfigFields.put("E2E_HOMEGATE_OVERRIDE", e2eHomegateOverrideEnv.stringField()) buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) - buildConfigFields.put("GEO", geoEnv.booleanField()) buildConfigFields.put("FEATURE_PAYKIT_UI_DISABLED", paykitUiDisabledEnv.booleanField()) buildConfigFields.put("LOCALES", provider { bcp47Locales.joinToString(",") }.stringField()) buildConfigFields.put( diff --git a/app/src/androidTest/java/to/bitkit/services/BlocktankTest.kt b/app/src/androidTest/java/to/bitkit/services/BlocktankTest.kt index bcf40cfef7..944df5abc7 100644 --- a/app/src/androidTest/java/to/bitkit/services/BlocktankTest.kt +++ b/app/src/androidTest/java/to/bitkit/services/BlocktankTest.kt @@ -1,26 +1,18 @@ package to.bitkit.services import androidx.test.platform.app.InstrumentationRegistry -import com.synonym.bitkitcore.BtOrderState -import com.synonym.bitkitcore.BtOrderState2 -import com.synonym.bitkitcore.CreateCjitOptions -import com.synonym.bitkitcore.CreateOrderOptions -import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.initDb import com.synonym.bitkitcore.updateBlocktankUrl import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.runBlocking import org.junit.Before -import org.junit.Ignore import org.junit.Rule import org.junit.Test import to.bitkit.env.Env import to.bitkit.test.annotations.CoreServiceIntegration import to.bitkit.test.annotations.DeviceIntegration import javax.inject.Inject -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -85,136 +77,4 @@ class BlocktankTest { assertTrue(info.onchain.feeRates.mid > 0u, "Mid fee rate should be greater than 0") assertTrue(info.onchain.feeRates.slow > 0u, "Slow fee rate should be greater than 0") } - - @Ignore("Blocktank staging no longer creates orders or CJIT entries (synonymdev/blocktank-lsp-http#168)") - @Test - fun testCreateCjitEntry() = runBlocking { - // Test creating a CJIT order - val channelSizeSat = 100_000uL // 100k sats - val invoiceSat = 10_000uL // 10k sats for the invoice - val invoiceDescription = "Test CJIT" - val nodeId = "03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad" // Example node ID - val channelExpiryWeeks = 6u - val options = CreateCjitOptions(source = "bitkit", discountCode = null) - - // Create CJIT entry - val cjitEntry = service.blocktank.createCjit( - channelSizeSat = channelSizeSat, - invoiceSat = invoiceSat, - invoiceDescription = invoiceDescription, - nodeId = nodeId, - channelExpiryWeeks = channelExpiryWeeks, - options = options - ) - - // Verify CJIT entry - assertNotNull(cjitEntry, "CJIT entry should not be null") - assertTrue(cjitEntry.id.isNotEmpty(), "CJIT entry ID should not be empty") - assertEquals(cjitEntry.channelSizeSat, channelSizeSat, "Channel size should match requested amount") - assertTrue( - cjitEntry.source == "bitkit", - "Source should match expected value" - ) - assertNotNull(cjitEntry.lspNode, "LSP node should not be null") - assertTrue( - cjitEntry.lspNode.pubkey.isNotEmpty(), - "LSP node pubkey should not be empty" - ) - assertEquals(cjitEntry.nodeId, nodeId, "Node ID should match requested ID") - assertEquals( - cjitEntry.channelExpiryWeeks, - channelExpiryWeeks, - "Channel expiry weeks should match the requested value" - ) - - // Test getting CJIT entries - val entries = service.blocktank.cjitEntries( - entryIds = listOf(cjitEntry.id), - filter = null, - refresh = true - ) - assertTrue(entries.isNotEmpty(), "Should retrieve created CJIT entry") - assertEquals(entries.first().id, cjitEntry.id, "Retrieved entry should match created entry") - } - - @Ignore("Blocktank staging no longer creates orders or CJIT entries (synonymdev/blocktank-lsp-http#168)") - @Test - fun testOrders() = runBlocking { - // Test creating an order - val lspBalanceSat = 100_000uL - val channelExpiryWeeks = 2u - val options = CreateOrderOptions( - clientBalanceSat = 0uL, - lspNodeId = null, - couponCode = "", - source = "bitkit-android", - discountCode = null, - zeroConf = false, - zeroConfPayment = true, - zeroReserve = false, - clientNodeId = null, - signature = null, - timestamp = null, - refundOnchainAddress = null, - announceChannel = true - ) - - // Create Order - val order = service.blocktank.newOrder( - lspBalanceSat = lspBalanceSat, - channelExpiryWeeks = channelExpiryWeeks, - options = options - ) - - // Verify Order - assertNotNull(order, "Order should not be null") - assertTrue(order.id.isNotEmpty(), "Order ID should not be empty") - assertEquals(order.state, BtOrderState.CREATED, "Initial state should be created") - assertEquals(order.state2, BtOrderState2.CREATED, "Initial state2 should be created") - assertEquals(order.lspBalanceSat, lspBalanceSat, "LSP balance should match requested amount") - assertEquals(order.clientBalanceSat, 0uL, "Client balance should be zero") - assertEquals(order.channelExpiryWeeks, channelExpiryWeeks, "Channel expiry weeks should match") - assertEquals(order.source, "bitkit-android", "Source should match the expected value") - - // Test getting orders - val orders = service.blocktank.orders( - orderIds = listOf(order.id), - filter = null, - refresh = true - ) - assertTrue(orders.isNotEmpty(), "Orders list should not be empty") - assertEquals(orders.first().id, order.id, "Retrieved order should match created order") - } - - @Test - fun testOrderEstimate() = runBlocking { - val lspBalanceSat = 100_000uL - val channelExpiryWeeks = 2u - val options = CreateOrderOptions( - clientBalanceSat = 0uL, - lspNodeId = null, - couponCode = "", - source = "bitkit-android", - discountCode = null, - zeroConf = false, - zeroConfPayment = true, - zeroReserve = false, - clientNodeId = null, - signature = null, - timestamp = null, - refundOnchainAddress = null, - announceChannel = true, - ) - - val estimate: IBtEstimateFeeResponse2 = service.blocktank.estimateFee( - lspBalanceSat = lspBalanceSat, - channelExpiryWeeks = channelExpiryWeeks, - options = options - ) - - assertNotNull(estimate.min0ConfTxFee, "Min 0 conf tx fee should not be null") - assertNotEquals(0uL, estimate.feeSat, "Fee should not be zero") - assertNotEquals(0uL, estimate.networkFeeSat, "Network fee should not be zero") - assertNotEquals(0uL, estimate.serviceFeeSat, "Service fee should not be zero") - } } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt index 2397670d96..a2cc70ebc2 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/transfer/FundingScreenTest.kt @@ -1,11 +1,8 @@ package to.bitkit.ui.screens.transfer -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.filterToOne -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import dagger.hilt.android.testing.HiltAndroidRule @@ -13,23 +10,11 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Before import org.junit.Rule import org.junit.Test -import to.bitkit.models.BalanceState import to.bitkit.test.annotations.ComposeUi -import to.bitkit.ui.LocalBalances import to.bitkit.ui.theme.AppThemeSurface import kotlin.test.assertEquals -/** - * Regression pins for #811 — the three funding options stay distinct and keep their own routes. - * - * "Use Other Wallet" deliberately opens the receive/CJIT flow (matching iOS `FundingOptions`); - * manual external-node setup is the third option. Swapping those two is the regression. - * - * Note on selectors: `FundTransfer` tags two overlapping nodes — the button itself and a - * conditional full-size overlay that only becomes clickable at a zero fundable balance. Selecting - * by index would depend on semantics ordering, so these tests filter on "clickable and enabled", - * which is what actually distinguishes the two at either balance. - */ +/** Blocktank no longer sells channels, so Manual Setup is the only funding option that responds. */ @HiltAndroidTest @ComposeUi class FundingScreenTest { @@ -40,111 +25,32 @@ class FundingScreenTest { @get:Rule val hiltRule = HiltAndroidRule(this) - private var transferClicks = 0 - private var fundClicks = 0 private var manualClicks = 0 @Before fun setup() { hiltRule.inject() - transferClicks = 0 - fundClicks = 0 manualClicks = 0 - } - - private fun setContent(fundableBalance: ULong = 100_000uL, isGeoBlocked: Boolean = false) { composeTestRule.setContent { - CompositionLocalProvider( - LocalBalances provides BalanceState(channelFundableBalance = fundableBalance) - ) { - AppThemeSurface { - FundingScreen( - isGeoBlocked = isGeoBlocked, - onTransfer = { transferClicks++ }, - onFund = { fundClicks++ }, - onManual = { manualClicks++ }, - ) - } + AppThemeSurface { + FundingScreen(onManual = { manualClicks++ }) } } } - // A disabled Compose button still reports an OnClick action, so `hasClickAction()` alone matches - // both nodes at a zero balance — the enabled check is what makes the selection deterministic. - private fun clickableTransferOption() = composeTestRule - .onAllNodesWithTag("FundTransfer") - .filterToOne(hasClickAction() and isEnabled()) - - @Test - fun whenScreenLoaded_shouldShowAllThreeFundingOptions() { - setContent() - - clickableTransferOption().assertExists() - composeTestRule.onNodeWithTag("FundReceive").assertExists() - composeTestRule.onNodeWithTag("FundManual").assertExists() - } - @Test - fun whenUseOtherWalletTapped_shouldInvokeOnlyOnFund() { - setContent() - - composeTestRule.onNodeWithTag("FundReceive").performClick() - composeTestRule.waitForIdle() - - assertEquals(1, fundClicks) - assertEquals(0, manualClicks) - assertEquals(0, transferClicks) + fun manualSetupIsTheOnlyEnabledOption() { + composeTestRule.onNodeWithTag("FundManual").assertIsEnabled() + composeTestRule.onNodeWithTag("FundTransfer").assertIsNotEnabled() + composeTestRule.onNodeWithTag("FundHardware").assertIsNotEnabled() + composeTestRule.onNodeWithTag("FundReceive").assertIsNotEnabled() } @Test - fun whenManualSetupTapped_shouldInvokeOnlyOnManual() { - setContent() - + fun manualSetupTapInvokesOnManual() { composeTestRule.onNodeWithTag("FundManual").performClick() composeTestRule.waitForIdle() assertEquals(1, manualClicks) - assertEquals(0, fundClicks) - assertEquals(0, transferClicks) - } - - @Test - fun whenTransferFromSavingsTapped_shouldInvokeOnlyOnTransfer() { - setContent() - - clickableTransferOption().performClick() - composeTestRule.waitForIdle() - - assertEquals(1, transferClicks) - assertEquals(0, fundClicks) - assertEquals(0, manualClicks) - } - - @Test - fun whenGeoBlocked_onlyManualSetupShouldInvoke() { - setContent(isGeoBlocked = true) - - clickableTransferOption().performClick() - composeTestRule.onNodeWithTag("FundReceive").performClick() - composeTestRule.onNodeWithTag("FundManual").performClick() - composeTestRule.waitForIdle() - - assertEquals(0, transferClicks) - assertEquals(0, fundClicks) - assertEquals(1, manualClicks) - } - - @Test - fun whenFundableBalanceIsZero_transferShouldNotRouteToTheTransferFlow() { - setContent(fundableBalance = 0uL) - - // At a zero fundable balance the button is disabled and the overlay takes the gesture to - // raise the no-funds alert, so the transfer callback must not fire. - clickableTransferOption().performClick() - composeTestRule.waitForIdle() - - assertEquals(0, transferClicks) - assertEquals(0, fundClicks) - assertEquals(0, manualClicks) } } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSelectionTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSelectionTest.kt index 954883f335..72a69ac745 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSelectionTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSelectionTest.kt @@ -55,7 +55,6 @@ class ReceiveAutoTabSelectionTest { composeTestRule.setContent { AppThemeSurface { ReceiveQrScreen( - cjitInvoice = null, walletState = WALLET_STATE, lightningState = lightningState, onClickEditInvoice = { editedTabs += it }, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveHardwareFlowTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveHardwareFlowTest.kt index 4a46dabf50..50f6247f5a 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveHardwareFlowTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveHardwareFlowTest.kt @@ -52,7 +52,6 @@ class ReceiveHardwareFlowTest { } } else { ReceiveQrScreen( - cjitInvoice = null, walletState = WALLET_STATE, lightningState = LightningState(), onClickEditInvoice = {}, diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveNavigationTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveNavigationTest.kt index 6e4af3e23b..bf29025c58 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveNavigationTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/ReceiveNavigationTest.kt @@ -35,10 +35,10 @@ class ReceiveNavigationTest { private var sheetDismissed = false @Test - fun deepLinkedAmountReturnsToQr() { - setGraph(ReceiveRoute.Amount) + fun deepLinkedTransferUnavailableReturnsToQr() { + setGraph(ReceiveRoute.TransferUnavailable) - assertOn(ReceiveRoute.Amount::class) + assertOn(ReceiveRoute.TransferUnavailable::class) pressBack() assertOn(ReceiveRoute.QR::class) assertFalse(sheetDismissed) @@ -97,7 +97,7 @@ class ReceiveNavigationTest { LaunchedEffect(startRoute) { controller.navigateToReceiveStart(startRoute) } NavHost(navController = controller, startDestination = startRoute.rootRoute()) { composable { Text("QR") } - composable { Text("Amount") } + composable { Text("Transfer Unavailable") } composable { Text("Details") } composable { Text("Recipient") } composable { Text("Sent") } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 40427869ec..1bee5866b0 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -33,7 +33,6 @@ internal object Env { val isDebug = BuildConfig.DEBUG const val isE2eTest = BuildConfig.E2E - const val isGeoblockingEnabled = BuildConfig.GEO val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() val isLocalE2eBackend = isE2eTest && e2eBackend == "local" const val e2eLocalHost = BuildConfig.E2E_LOCAL_HOST @@ -132,7 +131,6 @@ internal object Env { else -> "https://bitkit.stag0.blocktank.to/fx/rates/btc" } - const val geoCheckUrl = "https://api1.blocktank.to/api/geocheck" const val chatwootUrl = "https://synonym.to/api/chatwoot" const val newsBaseUrl = "https://feeds.synonym.to/news-feed/api" const val mempoolBaseUrl = "https://mempool.space/api" diff --git a/app/src/main/java/to/bitkit/models/CjitQuoteValidator.kt b/app/src/main/java/to/bitkit/models/CjitQuoteValidator.kt deleted file mode 100644 index 5b28cd337f..0000000000 --- a/app/src/main/java/to/bitkit/models/CjitQuoteValidator.kt +++ /dev/null @@ -1,22 +0,0 @@ -package to.bitkit.models - -import to.bitkit.utils.ServiceError - -object CjitQuoteValidator { - fun validate( - invoiceSat: ULong, - feeSat: ULong, - channelSizeSat: ULong, - ): Result { - if (feeSat >= invoiceSat) { - return Result.failure(ServiceError.CjitQuoteInvalid()) - } - - val netReceiveSat = invoiceSat - feeSat - if (channelSizeSat < netReceiveSat) { - return Result.failure(ServiceError.CjitQuoteInvalid()) - } - - return Result.success(Unit) - } -} diff --git a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt index 43edaf2b09..cf30c1d05b 100644 --- a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt +++ b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt @@ -8,18 +8,15 @@ enum class ReceiveLiquiditySource { sealed interface ReceiveAdditionalLiquidityAction { data object None : ReceiveAdditionalLiquidityAction - data object ChooseAmount : ReceiveAdditionalLiquidityAction - data class CreateCjit(val amountSats: ULong) : ReceiveAdditionalLiquidityAction - data object GeoBlocked : ReceiveAdditionalLiquidityAction + + /** More inbound capacity needs a new channel, and Blocktank no longer sells them. */ + data object TransferUnavailable : ReceiveAdditionalLiquidityAction } data class ReceiveAdditionalLiquidityParams( val source: ReceiveLiquiditySource, val invoiceAmountSats: ULong, val inboundCapacitySats: ULong?, - val minCjitSats: ULong?, - val maxCjitAmountSats: ULong?, - val isGeoBlocked: Boolean, ) object ReceiveLiquidityDecision { @@ -43,25 +40,10 @@ object ReceiveLiquidityDecision { !needsInboundLiquidity(params.invoiceAmountSats, params.inboundCapacitySats) -> ReceiveAdditionalLiquidityAction.None (params.inboundCapacitySats ?: 0uL) == 0uL -> ReceiveAdditionalLiquidityAction.None - params.isGeoBlocked -> ReceiveAdditionalLiquidityAction.GeoBlocked - shouldChooseAmount(params) -> ReceiveAdditionalLiquidityAction.ChooseAmount - else -> ReceiveAdditionalLiquidityAction.CreateCjit(params.invoiceAmountSats) + else -> ReceiveAdditionalLiquidityAction.TransferUnavailable } } - fun needsCjitLimitsForAdditionalLiquidity( - source: ReceiveLiquiditySource, - invoiceAmountSats: ULong, - inboundCapacitySats: ULong?, - isGeoBlocked: Boolean, - ): Boolean { - if (source != ReceiveLiquiditySource.SPENDING) return false - if (!needsInboundLiquidity(invoiceAmountSats, inboundCapacitySats)) return false - if ((inboundCapacitySats ?: 0uL) == 0uL) return false - - return !isGeoBlocked - } - fun needsInboundLiquidity( invoiceAmountSats: ULong, inboundCapacitySats: ULong?, @@ -74,12 +56,4 @@ object ReceiveLiquidityDecision { return invoiceAmountSats > inbound } - - private fun shouldChooseAmount(params: ReceiveAdditionalLiquidityParams): Boolean { - val min = params.minCjitSats ?: 0uL - val max = params.maxCjitAmountSats?.takeIf { it > 0uL } ?: return true - if (params.invoiceAmountSats == 0uL || min == 0uL) return true - - return params.invoiceAmountSats < min || params.invoiceAmountSats > max - } } diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 26b920b112..fbf9e182ac 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -1,39 +1,23 @@ package to.bitkit.repositories import androidx.compose.runtime.Stable -import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.CJitStateEnum -import com.synonym.bitkitcore.ChannelLiquidityOptions -import com.synonym.bitkitcore.ChannelLiquidityParams -import com.synonym.bitkitcore.CreateCjitOptions -import com.synonym.bitkitcore.CreateOrderOptions -import com.synonym.bitkitcore.DefaultLspBalanceParams -import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry -import com.synonym.bitkitcore.calculateChannelLiquidityOptions -import com.synonym.bitkitcore.getDefaultLspBalance -import com.synonym.bitkitcore.giftOrder -import com.synonym.bitkitcore.giftPay import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -43,54 +27,30 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withTimeoutOrNull -import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.Event -import to.bitkit.async.ServiceQueue import to.bitkit.async.appScope -import to.bitkit.data.BlocktankRefundAddress import to.bitkit.data.CacheStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env -import to.bitkit.ext.calculateRemoteBalance -import to.bitkit.ext.nowTimestamp import to.bitkit.ext.runSuspendCatching import to.bitkit.models.BlocktankBackupV1 -import to.bitkit.models.CjitQuoteValidator -import to.bitkit.models.EUR -import to.bitkit.models.msatCeilOf -import to.bitkit.models.safe import to.bitkit.services.CoreService -import to.bitkit.services.LightningService -import to.bitkit.utils.AppError import to.bitkit.utils.Logger -import to.bitkit.utils.ServiceError -import java.math.BigDecimal import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton -import kotlin.math.ceil -import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds @Singleton class BlocktankRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, - private val lightningService: LightningService, - private val currencyRepo: CurrencyRepo, private val cacheStore: CacheStore, @Named("enablePolling") private val enablePolling: Boolean, - private val lightningRepo: LightningRepo, ) { private val repoScope = appScope(bgDispatcher, TAG) - private val refundAddressMutex = Mutex() private val _blocktankState = MutableStateFlow(BlocktankState()) val blocktankState: StateFlow = _blocktankState.asStateFlow() @@ -240,201 +200,6 @@ class BlocktankRepo @Inject constructor( } } - suspend fun refreshMinCjitSats() = withContext(bgDispatcher) { - runCatching { - val lspBalance = getDefaultLspBalance(clientBalance = 0u) - val fees = estimateOrderFee(spendingBalanceSats = 0u, receivingBalanceSats = lspBalance).getOrThrow() - - val minimum = (ceil(fees.feeSat.toDouble() * 1.1 / 1000) * 1000).toInt() - _blocktankState.update { it.copy(minCjitSats = minimum) } - - Logger.debug("Updated minCjitSats to: $minimum", context = TAG) - }.onFailure { - Logger.error("Failed to refresh minCjitSats", it, context = TAG) - } - } - - suspend fun createCjit( - amountSats: ULong, - description: String = "", - ): Result = withContext(bgDispatcher) { - runSuspendCatching { - if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() - val maxChannelSizeSat = freshMaxChannelSizeSat() - if (maxChannelSizeSat != null && amountSats > maxChannelSizeSat) { - throw ServiceError.ChannelSizeExceedsMaximum() - } - val lspBalance = getDefaultLspBalance(clientBalance = amountSats) - if (!canFitChannelSize(amountSats, lspBalance)) { - throw ServiceError.ChannelSizeExceedsMaximum() - } - val channelSizeSat = amountSats.safe() + lspBalance.safe() - - val cjitEntry = coreService.blocktank.createCjit( - channelSizeSat = channelSizeSat, - invoiceSat = amountSats, - invoiceDescription = description, - nodeId = nodeId, - channelExpiryWeeks = DEFAULT_CHANNEL_EXPIRY_WEEKS, - options = CreateCjitOptions(source = DEFAULT_SOURCE, discountCode = null) - ) - CjitQuoteValidator.validate( - invoiceSat = amountSats, - feeSat = cjitEntry.feeSat, - channelSizeSat = cjitEntry.channelSizeSat, - ).getOrThrow() - - repoScope.launch { refreshOrders() } - - return@runSuspendCatching cjitEntry - }.fold( - onSuccess = { Result.success(it) }, - onFailure = { Result.failure(it.toCjitError()) }, - ).onFailure { - Logger.error("Failed to create CJIT", it, context = TAG) - } - } - - suspend fun maxCjitAmountSats(): Result = withContext(bgDispatcher) { - runSuspendCatching { - val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runSuspendCatching null - var lowerBound = 0uL - var upperBound = maxChannelSizeSat - - while (lowerBound < upperBound) { - val distance = upperBound.safe() - lowerBound.safe() - val step = (distance.safe() + 1uL.safe()) / 2uL - val candidate = lowerBound.safe() + step.safe() - if (canCreateCjit(candidate, maxChannelSizeSat)) { - lowerBound = candidate - } else { - upperBound = candidate - 1uL - } - } - - lowerBound - }.onFailure { - Logger.error("Failed to calculate max CJIT amount", it, context = TAG) - } - } - - suspend fun createOrder( - spendingBalanceSats: ULong, - receivingBalanceSats: ULong = spendingBalanceSats * 2u, - channelExpiryWeeks: UInt = DEFAULT_CHANNEL_EXPIRY_WEEKS, - ): Result = withContext(bgDispatcher) { - runSuspendCatching { - if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() - if (lightningService.nodeId == null) throw ServiceError.NodeNotStarted() - - currentCoroutineContext().ensureActive() - val refundAddress = getBlocktankRefundAddress() - currentCoroutineContext().ensureActive() - val baseOptions = defaultCreateOrderOptions(clientBalanceSat = spendingBalanceSats) - val options = baseOptions.copy(refundOnchainAddress = refundAddress) - currentCoroutineContext().ensureActive() - - Logger.info( - "Buying channel with " + - "clientBalanceSat: '$spendingBalanceSats', " + - "lspBalanceSat: '$receivingBalanceSats', " + - "channelExpiryWeeks: '$channelExpiryWeeks', " + - "zeroConf: '${options.zeroConf}', " + - "zeroReserve: '${options.zeroReserve}', " + - "announceChannel: '${options.announceChannel}'", - context = TAG, - ) - - val order = coreService.blocktank.newOrder( - lspBalanceSat = receivingBalanceSats, - channelExpiryWeeks = channelExpiryWeeks, - options = options, - ) - - repoScope.launch { refreshOrders() } - - return@runSuspendCatching order - }.onFailure { - Logger.error("Failed to create order", it, context = TAG) - } - } - - private suspend fun getBlocktankRefundAddress(): String = refundAddressMutex.withLock { - val cached = cacheStore.data.first().blocktankRefundAddress - if (cached == null) return@withLock allocateBlocktankRefundAddress() - - if (cached.index !in 0..Int.MAX_VALUE.toLong()) { - throw AppError("Invalid cached Blocktank refund address index") - } - if (cached.address.isBlank()) { - throw AppError("Invalid cached Blocktank refund address") - } - - val index = cached.index.toInt() - val derived = lightningRepo.addressInfoForType(AddressType.P2WPKH, index).getOrThrow() - if (derived.index != index || derived.address != cached.address) { - throw AppError("Cached Blocktank refund address does not belong to the active wallet") - } - - lightningRepo.revealReceiveAddresses(index, AddressType.P2WPKH).getOrThrow() - if (!coreService.isAddressUsed(cached.address)) return@withLock cached.address - - allocateBlocktankRefundAddress(afterIndex = index) - } - - private suspend fun allocateBlocktankRefundAddress(afterIndex: Int? = null): String { - var previousIndex = afterIndex - repeat(REFUND_ADDRESS_ALLOCATION_LIMIT) { - val derived = lightningRepo.newAddressInfoForType(AddressType.P2WPKH).getOrThrow() - val lastIndex = previousIndex - val hasInvalidIndex = derived.index !in 0..Int.MAX_VALUE - val didNotAdvance = lastIndex != null && derived.index <= lastIndex - if (hasInvalidIndex || derived.address.isBlank() || didNotAdvance) { - throw AppError("Failed to allocate a valid advancing Blocktank refund address") - } - previousIndex = derived.index - - if (coreService.isAddressUsed(derived.address)) return@repeat - - cacheStore.update { - it.copy( - blocktankRefundAddress = BlocktankRefundAddress( - address = derived.address, - index = derived.index.toLong(), - ), - ) - } - return derived.address - } - - throw AppError("Failed to allocate an unused Blocktank refund address") - } - - suspend fun estimateOrderFee( - spendingBalanceSats: ULong, - receivingBalanceSats: ULong, - channelExpiryWeeks: UInt = DEFAULT_CHANNEL_EXPIRY_WEEKS, - ): Result = withContext(bgDispatcher) { - Logger.info("Estimating order fee for spendingSats=$spendingBalanceSats, receivingSats=$receivingBalanceSats") - - runCatching { - val options = defaultCreateOrderOptions(clientBalanceSat = spendingBalanceSats) - - val estimate = coreService.blocktank.estimateFee( - lspBalanceSat = receivingBalanceSats, - channelExpiryWeeks = channelExpiryWeeks, - options = options, - ) - - Logger.debug("Estimated order fee: '$estimate'") - - return@runCatching estimate - }.onFailure { - Logger.error("Failed to estimate order fee", it, context = TAG) - } - } - suspend fun openChannel(orderId: String): Result = withContext(bgDispatcher) { runCatching { Logger.debug("Opening channel for order: '$orderId'", context = TAG) @@ -493,102 +258,6 @@ class BlocktankRepo @Inject constructor( } } - private suspend fun defaultCreateOrderOptions(clientBalanceSat: ULong): CreateOrderOptions { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() - val timestamp = nowTimestamp().toString() - val signature = lightningService.sign("channelOpen-$timestamp") - - return CreateOrderOptions( - clientBalanceSat = clientBalanceSat, - lspNodeId = null, - couponCode = "", - source = DEFAULT_SOURCE, - discountCode = null, - zeroConf = true, - zeroConfPayment = false, - zeroReserve = true, - clientNodeId = nodeId, - signature = signature, - timestamp = timestamp, - refundOnchainAddress = null, - announceChannel = false, - ) - } - - suspend fun getDefaultLspBalance(clientBalance: ULong): ULong = withContext(bgDispatcher) { - if (_blocktankState.value.info == null) { - refreshInfo() - } - - val satsPerEur = getSatsPerEur() - ?: throw ServiceError.CurrencyRateUnavailable() - - val params = DefaultLspBalanceParams( - clientBalanceSat = clientBalance, - maxChannelSizeSat = _blocktankState.value.info?.options?.maxChannelSizeSat ?: 0uL, - satsPerEur = satsPerEur - ) - - return@withContext getDefaultLspBalance(params) - } - - private suspend fun freshMaxChannelSizeSat(): ULong? { - refreshInfo() - - return _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL } - } - - private suspend fun canCreateCjit(amountSats: ULong, maxChannelSizeSat: ULong): Boolean { - if (amountSats > maxChannelSizeSat) return false - - val lspBalance = getDefaultLspBalance(clientBalance = amountSats) - val remainingCapacity = maxChannelSizeSat.safe() - amountSats.safe() - return lspBalance <= remainingCapacity - } - - private fun canFitChannelSize(amountSats: ULong, lspBalance: ULong): Boolean { - val maxChannelSizeSat = _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL } - ?: return true - - if (amountSats > maxChannelSizeSat) return false - - val remainingCapacity = maxChannelSizeSat.safe() - amountSats.safe() - return lspBalance <= remainingCapacity - } - - fun calculateLiquidityOptions(clientBalanceSat: ULong): Result { - val blocktankInfo = blocktankState.value.info - ?: return Result.failure(ServiceError.BlocktankInfoUnavailable()) - - val satsPerEur = getSatsPerEur() - ?: return Result.failure(ServiceError.CurrencyRateUnavailable()) - - val existingChannelsTotalSat = totalBtChannelsValueSats(blocktankInfo) - - val params = ChannelLiquidityParams( - clientBalanceSat = clientBalanceSat, - existingChannelsTotalSat = existingChannelsTotalSat, - minChannelSizeSat = blocktankInfo.options.minChannelSizeSat, - maxChannelSizeSat = blocktankInfo.options.maxChannelSizeSat, - satsPerEur = satsPerEur - ) - - return Result.success(calculateChannelLiquidityOptions(params)) - } - - private fun getSatsPerEur(): ULong? { - return currencyRepo.convertFiatToSats(BigDecimal(1), EUR).getOrNull() - } - - private fun totalBtChannelsValueSats(info: IBtInfo?): ULong { - val channels = lightningRepo.getChannels() ?: return 0u - val btNodeIds = info?.nodes?.map { it.pubkey } ?: return 0u - - val btChannels = channels.filter { btNodeIds.contains(it.counterpartyNodeId) } - - return btChannels.sumOf { it.channelValueSats } - } - suspend fun resetState() = withContext(bgDispatcher) { _blocktankState.update { BlocktankState() } Logger.debug("Blocktank state reset", context = TAG) @@ -617,192 +286,18 @@ class BlocktankRepo @Inject constructor( } } - suspend fun claimGiftCode( - code: String, - amount: ULong, - waitTimeout: Duration = TIMEOUT_GIFT_CODE, - ): Result = withContext(bgDispatcher) { - runCatching { - require(code.isNotBlank()) { "Gift code cannot be blank" } - require(amount > 0u) { "Gift amount must be positive" } - - Logger.debug("Starting gift code claim: amount=$amount, timeout=$waitTimeout", context = TAG) - - lightningRepo.executeWhenNodeRunning( - operationName = "claimGiftCode", - waitTimeout = waitTimeout, - ) { - delay(PEER_CONNECTION_DELAY_MS) - - val channels = lightningRepo.getChannelsAsync().getOrThrow() - val maxInboundCapacity = channels.calculateRemoteBalance() - - Logger.debug( - "Liquidity check: maxInbound=$maxInboundCapacity, required=$amount", - context = TAG - ) - - if (maxInboundCapacity >= amount) { - Logger.debug("Sufficient liquidity available, claiming with existing channel", context = TAG) - Result.success(claimGiftCodeWithLiquidity(code, amount)) - } else { - Logger.debug("Insufficient liquidity, opening new channel", context = TAG) - Result.success(claimGiftCodeWithoutLiquidity(code, amount)) - } - }.getOrThrow() - }.onFailure { - Logger.error("Failed to claim gift code", it, context = TAG) - } - } - - private suspend fun claimGiftCodeWithLiquidity(code: String, amount: ULong): GiftClaimResult = coroutineScope { - val invoice = lightningRepo.createInvoice( - amountSats = null, - description = "blocktank-gift-code:$code", - expirySeconds = 1.hours.inWholeSeconds.toUInt(), - ).getOrThrow() - - val expectedPaymentHash = Bolt11Invoice.fromStr(invoice).paymentHash() - - Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG) - - val paymentReceivedDeferred = async(start = CoroutineStart.UNDISPATCHED) { - lightningRepo.nodeEvents - .filterIsInstance() - .first { it.paymentHash == expectedPaymentHash } - } - - val giftResponse = ServiceQueue.CORE.background { - giftPay(invoice = invoice) - } - - Logger.debug( - "Gift payment request completed: id='${giftResponse.id}', awaiting LDK PaymentReceived", - context = TAG, - ) - - val paymentReceived = withTimeoutOrNull(GIFT_PAYMENT_RECEIVE_TIMEOUT) { - paymentReceivedDeferred.await() - } - - if (paymentReceived == null) { - paymentReceivedDeferred.cancel() - throw ServiceError.GiftClaimPaymentNotReceived() - } - - Logger.debug( - "Gift payment confirmed by LDK: hash='${paymentReceived.paymentHash}', " + - "amountMsat='${paymentReceived.amountMsat}'", - context = TAG, - ) - - val receivedSats = msatCeilOf(paymentReceived.amountMsat).toLong() - - GiftClaimResult.SuccessWithLiquidity( - paymentHashOrTxId = paymentReceived.paymentHash, - sats = receivedSats.takeIf { it > 0 } ?: amount.toLong(), - invoice = invoice, - code = code, - ) - } - - private suspend fun claimGiftCodeWithoutLiquidity(code: String, amount: ULong): GiftClaimResult { - val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() - - Logger.debug("Creating gift order for code (insufficient liquidity)", context = TAG) - - val order = ServiceQueue.CORE.background { - giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code") - } - - val orderId = checkNotNull(order.orderId) { "Order ID is null after gift order creation" } - Logger.debug("Gift order created: $orderId", context = TAG) - - val openedOrder = openChannel(orderId).getOrThrow() - Logger.debug("Channel opened for gift order: ${openedOrder.id}", context = TAG) - - val fundingTxId = openedOrder.channel?.fundingTx?.id - if (fundingTxId == null) { - Logger.warn("Channel opened but funding transaction ID is null", context = TAG) - } - - return GiftClaimResult.SuccessWithoutLiquidity( - paymentHashOrTxId = fundingTxId ?: orderId, - sats = amount.toLong(), - invoice = openedOrder.payment?.bolt11Invoice?.request ?: "", - code = code, - ) - } - companion object { private const val TAG = "BlocktankRepo" - private const val REFUND_ADDRESS_ALLOCATION_LIMIT = 20 - private const val DEFAULT_CHANNEL_EXPIRY_WEEKS = 6u - private const val DEFAULT_SOURCE = "bitkit-android" - private const val PEER_CONNECTION_DELAY_MS = 2_000L - private val TIMEOUT_GIFT_CODE = 30.seconds - private val GIFT_PAYMENT_RECEIVE_TIMEOUT = 45.seconds private const val CJIT_REFRESH_ATTEMPTS = 3 private val CJIT_REFRESH_TIMEOUT = 5.seconds private val CJIT_REFRESH_RETRY_DELAY = 1.seconds } } -internal fun Throwable.toCjitError(): Throwable { - if (this is ServiceError.ChannelSizeExceedsMaximum || - this is ServiceError.CjitQuoteInvalid || - this is ServiceError.NodeCapacityUnavailable - ) { - return this - } - - return when { - isNodeCapacityError() -> ServiceError.NodeCapacityUnavailable() - isMaxChannelSizeError() -> ServiceError.ChannelSizeExceedsMaximum() - else -> this - } -} - -private fun Throwable.isNodeCapacityError(): Boolean { - return toString().contains("capacity is above our capacity limit", ignoreCase = true) -} - -private fun Throwable.isMaxChannelSizeError(): Boolean { - val description = toString() - val maximumErrors = listOf( - "Channel size is too big", - "channelSizeExceedsMaximum", - "maxChannelSizeSat", - ) - return maximumErrors.any { description.contains(it, ignoreCase = true) } -} - @Stable data class BlocktankState( val orders: ImmutableList = persistentListOf(), val paidOrders: ImmutableList = persistentListOf(), val cjitEntries: ImmutableList = persistentListOf(), val info: IBtInfo? = null, - val minCjitSats: Int? = null, ) - -sealed class GiftClaimResult { - abstract val paymentHashOrTxId: String - abstract val sats: Long - abstract val invoice: String - abstract val code: String - - data class SuccessWithLiquidity( - override val paymentHashOrTxId: String, - override val sats: Long, - override val invoice: String, - override val code: String, - ) : GiftClaimResult() - - data class SuccessWithoutLiquidity( - override val paymentHashOrTxId: String, - override val sats: Long, - override val invoice: String, - override val code: String, - ) : GiftClaimResult() -} diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index db09d7ac9e..5c5b9040dc 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -349,9 +349,6 @@ class LightningRepo @Inject constructor( if (shouldCancelPendingStop) cancelPendingStop() - // The geocheck needs no node, so screens gated on it are right before the node is up. - scope.launch { updateGeoBlockState() } - eventHandler?.let { _eventHandlers.add(it) } // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) @@ -405,7 +402,6 @@ class LightningRepo @Inject constructor( // Initial state sync syncState() logNodeSupportSummary("node started") - updateGeoBlockState() refreshChannelCache() if (shouldValidateGraph && !lightningService.aresRequiredPeersInNetworkGraph()) { @@ -655,19 +651,6 @@ class LightningRepo @Inject constructor( fun setWiping(enabled: Boolean) = run { isWiping = enabled } - /** Blocks a transfer to spending unless the geocheck confirms the region is allowed. */ - suspend fun isTransferBlocked(): Boolean = withContext(bgDispatcher) { - coreService.isGeoBlocked(blockOnError = true).also { blocked -> - _lightningState.update { it.copy(isGeoBlocked = blocked) } - } - } - - suspend fun updateGeoBlockState() = withContext(bgDispatcher) { - _lightningState.update { - it.copy(isGeoBlocked = coreService.isGeoBlocked()) - } - } - fun setInitNodeLifecycleState() { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Initializing) } } @@ -717,7 +700,7 @@ class LightningRepo @Inject constructor( lightningService.stop() clearProbeOutcomes() _lightningState.update { - LightningState(nodeLifecycleState = NodeLifecycleState.Stopped, isGeoBlocked = it.isGeoBlocked) + LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } } }.onFailure { @@ -730,7 +713,7 @@ class LightningRepo @Inject constructor( } else { // Node appears stopped, update state _lightningState.update { - LightningState(nodeLifecycleState = NodeLifecycleState.Stopped, isGeoBlocked = it.isGeoBlocked) + LightningState(nodeLifecycleState = NodeLifecycleState.Stopped) } } } @@ -1306,7 +1289,6 @@ class LightningRepo @Inject constructor( description: String, expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoice") { - updateGeoBlockState() runCatching { lightningService.receive(amountSats, description, expirySeconds) } } @@ -1315,7 +1297,6 @@ class LightningRepo @Inject constructor( description: String, expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoiceMsats") { - updateGeoBlockState() runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } } @@ -2741,7 +2722,6 @@ data class LightningState( val channels: ImmutableList = persistentListOf(), val balances: BalanceDetails? = null, val isSyncingWallet: Boolean = false, - val isGeoBlocked: Boolean = false, val lastSyncError: Throwable? = null, val lastSuccessfulSyncAt: Long? = null, ) { diff --git a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt index f77e623e9c..de0d5e8df0 100644 --- a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt @@ -160,7 +160,6 @@ class LogsRepo @Inject constructor( nodeId = state.nodeId, lifecycle = state.nodeLifecycleState.supportName(), isSyncingWallet = state.isSyncingWallet, - isGeoBlocked = state.isGeoBlocked, batterySettings = context.batterySettingsSnapshot(), lastSuccessfulSyncAt = state.lastSuccessfulSyncAt?.toString(), lastSyncError = state.lastSyncError?.javaClass?.simpleName, @@ -325,7 +324,6 @@ private data class SupportSnapshot( val nodeId: String, val lifecycle: String, val isSyncingWallet: Boolean, - val isGeoBlocked: Boolean, val batterySettings: BatterySettingsSnapshot, val lastSuccessfulSyncAt: String?, val lastSyncError: String?, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 4ef0a86315..739e88f308 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -443,11 +443,8 @@ class PaykitPaymentProofRepo @Inject constructor( * deadline passed. A record that is missing, carries a proof or needs recovery never counts as ended. */ suspend fun onchainRequestHasEnded(attempt: OnchainSendAttempt): Boolean = withContext(ioDispatcher) { - // Blocktank no longer sells channels, so its order ends once transfers are unavailable. - if (attempt.isBlocktankFunding) { - return@withContext attempt.canBeAbandoned && - (attempt.releaseApproved || lightningRepo.isTransferBlocked()) - } + // Blocktank no longer sells channels, so its order has always ended. + if (attempt.isBlocktankFunding) return@withContext attempt.canBeAbandoned val identity = currentIdentity()?.takeIf { PubkyPublicKeyFormat.matches(it, attempt.payerIdentity) } val record = attempt.takeIf { it.canBeAbandoned && identity != null }?.let { payerRecord(it) } val periodStartsAt = attempt.requestId?.billingPeriodStartsAt diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index c7c9173a01..7c9d6d8d40 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -284,7 +284,6 @@ class WalletRepo @Inject constructor( ) { runSuspendCatching { lightningRepo.syncState() - lightningRepo.updateGeoBlockState() refreshBip21Locked().getOrThrow() }.onFailure { _walletState.update { state -> state.copy(hasReceiveInvoiceError = true) } @@ -304,7 +303,6 @@ class WalletRepo @Inject constructor( return updateReceiveState { runSuspendCatching { lightningRepo.syncState() - lightningRepo.updateGeoBlockState() refreshAddressForInvoice().getOrThrow() updateBip21InvoiceLocked(state.bip21AmountSats, state.bip21Description).getOrThrow() }.onFailure { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 68e09e837c..8799ad6776 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -8,12 +8,9 @@ import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.CJitStateEnum import com.synonym.bitkitcore.ClosedChannelDetails -import com.synonym.bitkitcore.CreateCjitOptions -import com.synonym.bitkitcore.CreateOrderOptions import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.GetAddressResponse import com.synonym.bitkitcore.GetAddressesResponse -import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry @@ -30,13 +27,10 @@ import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.ValidationResult import com.synonym.bitkitcore.WordCount import com.synonym.bitkitcore.addTags -import com.synonym.bitkitcore.createCjitEntry -import com.synonym.bitkitcore.createOrder import com.synonym.bitkitcore.deleteActivitiesByWalletId import com.synonym.bitkitcore.deleteActivityById import com.synonym.bitkitcore.deleteTransactionDetails import com.synonym.bitkitcore.deriveOnchainDescriptor -import com.synonym.bitkitcore.estimateOrderFeeFull import com.synonym.bitkitcore.getActivities import com.synonym.bitkitcore.getActivityById import com.synonym.bitkitcore.getActivityByTxId @@ -65,9 +59,7 @@ import com.synonym.bitkitcore.upsertInfo import com.synonym.bitkitcore.upsertOrders import com.synonym.bitkitcore.upsertTransactionDetails import com.synonym.bitkitcore.wipeAllDatabases -import io.ktor.client.HttpClient import io.ktor.client.request.get -import io.ktor.http.HttpStatusCode import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.first @@ -121,7 +113,6 @@ import com.synonym.bitkitcore.markActivityAsSeen as coreMarkActivityAsSeen @Singleton class CoreService @Inject constructor( private val lightningService: LightningService, - private val httpClient: HttpClient, private val cacheStore: CacheStore, private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, @@ -172,45 +163,6 @@ class CoreService @Inject constructor( } } - /** @param blockOnError whether a failed or unexpected geocheck counts as blocked. */ - @Suppress("KotlinConstantConditions") - suspend fun isGeoBlocked(blockOnError: Boolean = false): Boolean { - val tag = "GeoCheck" - if (!Env.isGeoblockingEnabled) { - Logger.verbose("Geoblocking disabled via build config", context = tag) - return false - } - - return ServiceQueue.CORE.background { - runCatching { - Logger.verbose("Checking geo status…", context = tag) - val response = httpClient.get(Env.geoCheckUrl) - - when (response.status.value) { - HttpStatusCode.OK.value -> { - Logger.verbose("Region allowed", context = tag) - false - } - - HttpStatusCode.Forbidden.value -> { - Logger.warn("Region blocked", context = tag) - true - } - - else -> { - Logger.warn( - "Unexpected status code: ${response.status.value}, defaulting to '$blockOnError'", - context = tag - ) - blockOnError - } - } - }.onFailure { - Logger.warn("Failed geocheck, defaulting isGeoBlocked to '$blockOnError'", it, context = tag) - }.getOrDefault(blockOnError) - } - } - suspend fun wipeData(): Result = ServiceQueue.CORE.background { runCatching { activity.invalidatePaykitContactBackfill() @@ -2183,26 +2135,6 @@ class BlocktankService( return Result.success(fees) } - suspend fun createCjit( - channelSizeSat: ULong, - invoiceSat: ULong, - invoiceDescription: String, - nodeId: String, - channelExpiryWeeks: UInt, - options: CreateCjitOptions, - ): IcJitEntry { - return ServiceQueue.CORE.background { - createCjitEntry( - channelSizeSat = channelSizeSat, - invoiceSat = invoiceSat, - invoiceDescription = invoiceDescription, - nodeId = nodeId, - channelExpiryWeeks = channelExpiryWeeks, - options = options - ) - } - } - suspend fun cjitEntries( entryIds: List? = null, filter: CJitStateEnum? = null, @@ -2216,30 +2148,6 @@ class BlocktankService( } } - suspend fun newOrder( - lspBalanceSat: ULong, - channelExpiryWeeks: UInt, - options: CreateOrderOptions, - ): IBtOrder { - return ServiceQueue.CORE.background { - createOrder(lspBalanceSat = lspBalanceSat, channelExpiryWeeks = channelExpiryWeeks, options = options) - } - } - - suspend fun estimateFee( - lspBalanceSat: ULong, - channelExpiryWeeks: UInt, - options: CreateOrderOptions? = null, - ): IBtEstimateFeeResponse2 { - return ServiceQueue.CORE.background { - estimateOrderFeeFull( - lspBalanceSat = lspBalanceSat, - channelExpiryWeeks = channelExpiryWeeks, - options = options, - ) - } - } - suspend fun orders( orderIds: List? = null, filter: BtOrderState2? = null, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 639629c5d1..e88eb163d2 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -132,29 +132,18 @@ import to.bitkit.ui.screens.subscriptions.CreateSubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionDetailScreen import to.bitkit.ui.screens.subscriptions.SubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionsScreen -import to.bitkit.ui.screens.transfer.FundingAdvancedScreen import to.bitkit.ui.screens.transfer.FundingScreen -import to.bitkit.ui.screens.transfer.LiquidityScreen import to.bitkit.ui.screens.transfer.SavingsAdvancedScreen import to.bitkit.ui.screens.transfer.SavingsAvailabilityScreen import to.bitkit.ui.screens.transfer.SavingsConfirmScreen import to.bitkit.ui.screens.transfer.SavingsIntroScreen import to.bitkit.ui.screens.transfer.SavingsProgressScreen -import to.bitkit.ui.screens.transfer.SettingUpScreen -import to.bitkit.ui.screens.transfer.SpendingAdvancedScreen -import to.bitkit.ui.screens.transfer.SpendingAmountScreen -import to.bitkit.ui.screens.transfer.SpendingConfirmScreen -import to.bitkit.ui.screens.transfer.SpendingIntroScreen -import to.bitkit.ui.screens.transfer.TransferIntroScreen import to.bitkit.ui.screens.transfer.external.ExternalAmountScreen import to.bitkit.ui.screens.transfer.external.ExternalConfirmScreen 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.hardware.SpendingAmountHwScreen -import to.bitkit.ui.screens.transfer.hardware.SpendingHwSignScreen -import to.bitkit.ui.screens.transfer.hardware.SpendingHwSignedScreen import to.bitkit.ui.screens.trezor.TrezorScreen import to.bitkit.ui.screens.wallets.HardwareWalletScreen import to.bitkit.ui.screens.wallets.HomeScreen @@ -219,7 +208,6 @@ import to.bitkit.ui.sheets.ChangePinSheet import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.DisablePinSheet import to.bitkit.ui.sheets.ForceTransferSheet -import to.bitkit.ui.sheets.GiftSheet import to.bitkit.ui.sheets.HighBalanceWarningSheet import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet @@ -250,7 +238,6 @@ import to.bitkit.viewmodels.CurrencyViewModel import to.bitkit.viewmodels.MainScreenEffect import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.SettingsViewModel -import to.bitkit.viewmodels.TransferEffect import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel @@ -665,7 +652,6 @@ fun ContentView( ) is Sheet.BTCPayConnection -> BTCPayConnectionSheet(sheet, appViewModel) - is Sheet.Gift -> GiftSheet(sheet, appViewModel) is Sheet.QrScanner -> QrScanningSheet(sheet, appViewModel) is Sheet.PubkyAuth -> PubkyAuthApprovalSheet( authUrl = sheet.authUrl, @@ -887,18 +873,6 @@ private fun RootNavHost( onHomeCalculatorInputActiveChanged: (Boolean) -> Unit, ) { val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { - transferViewModel.transferEffects.collect { effect -> - if (effect is TransferEffect.OnFundingPending) { - appViewModel.showPendingTransfer(effect.attempt) - } else if (effect is TransferEffect.OnTransferUnavailable) { - appViewModel.showSheet(Sheet.TransferUnavailable) - } else { - navController.navigateForTransferEffect(effect) - } - } - } - NavHost(navController, startDestination = Routes.Home) { home( walletViewModel = walletViewModel, @@ -996,17 +970,8 @@ private fun RootNavHost( // TODO extract transferNavigation navigationWithDefaultTransitions( - startDestination = Routes.TransferIntro, + startDestination = Routes.SavingsIntro, ) { - deepLinkableComposable { - TransferIntroScreen( - onContinueClick = { - navController.navigateToTransferFunding() - settingsViewModel.setHasSeenTransferIntro(true) - }, - onBackClick = { navController.popBackStack() }, - ) - } deepLinkableComposable { SavingsIntroScreen( onContinueClick = { @@ -1047,126 +1012,8 @@ private fun RootNavHost( onTransferUnavailable = { navController.navigateOnSavingsTransferExit() }, ) } - deepLinkableComposable { - SpendingIntroScreen( - onContinueClick = { - navController.navigateTo(Routes.SpendingAmount) - settingsViewModel.setHasSeenSpendingIntro(true) - }, - onBackClick = { navController.popBackStack() }, - ) - } - deepLinkableComposable { entry -> - val walletId = entry.toRoute().walletId - SpendingIntroScreen( - onContinueClick = { - navController.navigateTo(Routes.SpendingAmountHw(walletId)) - settingsViewModel.setHasSeenSpendingIntro(true) - }, - onBackClick = { navController.popBackStack() }, - ) - } - deepLinkableComposable { - val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() - SpendingAmountScreen( - viewModel = transferViewModel, - isOffline = connectivityState != ConnectivityState.CONNECTED, - onBackClick = { navController.popBackStack() }, - onQuoteReady = { navController.navigateTo(Routes.SpendingConfirm) }, - toastException = { appViewModel.toast(it) }, - toast = { title, description -> - appViewModel.toast( - type = Toast.ToastType.ERROR, - title = title, - description = description, - ) - }, - ) - } - deepLinkableComposable { entry -> - val walletId = entry.toRoute().walletId - val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() - SpendingAmountHwScreen( - walletId = walletId, - viewModel = transferViewModel, - isOffline = connectivityState != ConnectivityState.CONNECTED, - onBackClick = { navController.popBackStack() }, - onQuoteReady = { navController.navigateTo(Routes.SpendingHwSign(walletId)) }, - ) - } - composableWithDefaultTransitions { entry -> - val walletId = entry.toRoute().walletId - SpendingHwSignScreen( - walletId = walletId, - viewModel = transferViewModel, - onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.navigateToHome() }, - onLearnMoreClick = { navController.navigateTo(Routes.TransferLiquidity) }, - onAdvancedClick = { navController.navigateTo(Routes.SpendingAdvanced) }, - ) - } - composableWithDefaultTransitions { - SpendingHwSignedScreen( - viewModel = transferViewModel, - onContinue = { navController.navigateTo(Routes.SettingUp) }, - onCloseClick = { navController.navigateToHome() }, - ) - } - composableWithDefaultTransitions { - val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() - SpendingConfirmScreen( - viewModel = transferViewModel, - isOffline = connectivityState != ConnectivityState.CONNECTED, - onBackClick = { navController.popBackStack() }, - onCloseClick = { navController.navigateToHome() }, - onLearnMoreClick = { navController.navigateTo(Routes.TransferLiquidity) }, - onAdvancedClick = { navController.navigateTo(Routes.SpendingAdvanced) }, - ) - } - composableWithDefaultTransitions { - SpendingAdvancedScreen( - viewModel = transferViewModel, - onBackClick = { navController.popBackStack() }, - onQuoteReady = { navController.popBackStack() }, - ) - } - deepLinkableComposable { - LiquidityScreen( - onBackClick = { navController.popBackStack() }, - onContinueClick = { navController.popBackStack() } - ) - } - composableWithDefaultTransitions { - SettingUpScreen( - viewModel = transferViewModel, - onContinueClick = { - navController.navigateToHome() - } - ) - } deepLinkableComposable { - val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() - val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() - FundingScreen( - onTransfer = { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) - }, - onFund = { - scope.launch { - navController.navigateToHome() - delay(500) // Wait for nav to actually finish - appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.Amount)) - } - }, - onManual = { navController.navigateTo(Routes.ExternalNav) }, - onBackClick = { navController.popBackStack() }, - isGeoBlocked = isGeoBlocked, - ) - } - deepLinkableComposable { - FundingAdvancedScreen( - onLnurl = { appViewModel.showScannerSheet() }, onManual = { navController.navigateTo(Routes.ExternalNav) }, onBackClick = { navController.popBackStack() }, ) @@ -1301,8 +1148,6 @@ private fun NavGraphBuilder.home( ) } deepLinkableComposable { - val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() - val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() val onchainActivities by activityListViewModel.onchainActivities.collectAsStateWithLifecycle() val forceCloseRemainingDuration by appViewModel.forceCloseRemainingDuration.collectAsStateWithLifecycle() @@ -1311,21 +1156,13 @@ private fun NavGraphBuilder.home( onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, - onTransferToSpendingClick = { - if (isGeoBlocked) { - appViewModel.showSheet(Sheet.TransferUnavailable) - } else { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) - } - }, + onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, ) } deepLinkableComposable { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() - val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() - val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() @@ -1342,30 +1179,16 @@ private fun NavGraphBuilder.home( navController.navigateToTransferSavingsAvailability() } }, - onTransferFromSavingsClick = { - if (isGeoBlocked) { - appViewModel.showSheet(Sheet.TransferUnavailable) - } else { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) - } - }, + onTransferFromSavingsClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, onBackClick = { navController.popBackStack() }, ) } deepLinkableComposable { val walletId = it.toRoute().walletId - val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() - val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() HardwareWalletScreen( walletId = walletId, onActivityItemClick = { navController.navToActivityDetail(it) }, - onTransferToSpendingClick = { selectedDeviceId -> - if (isGeoBlocked) { - appViewModel.showSheet(Sheet.TransferUnavailable) - } else { - navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) - } - }, + onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, onBackClick = { navController.popBackStack() }, ) } @@ -2228,49 +2051,11 @@ fun NavController.navigateOnSavingsTransferExit() { navigateToHome() } -fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean) = - navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro)) - -fun NavController.navigateToTransferSpendingStart( - hasSeenSpendingIntro: Boolean, - walletId: String, -) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, walletId)) - internal fun shouldDismissSheetForScreenLink(handled: Boolean, currentSheet: Sheet?): Boolean = handled && currentSheet != null internal fun receiveSheetPresentationKey(sheet: Sheet.Receive): String = sheet.presentationId -internal fun transferEffectDestination(effect: TransferEffect): Routes? = when (effect) { - TransferEffect.OnHwTxSigned -> Routes.SpendingHwSigned - TransferEffect.OnSpendingFundingPaid -> Routes.SettingUp - else -> null -} - -internal fun NavController.navigateForTransferEffect(effect: TransferEffect) { - val destination = transferEffectDestination(effect) ?: return - navigateTo(destination) { - if (effect is TransferEffect.OnSpendingFundingPaid) { - popUpTo { inclusive = true } - } - } -} - -internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes = when { - hasSeenSpendingIntro -> Routes.SpendingAmount - else -> Routes.SpendingIntro -} - -internal fun transferSpendingStartRoute( - hasSeenSpendingIntro: Boolean, - walletId: String, -): Routes = when { - hasSeenSpendingIntro -> Routes.SpendingAmountHw(walletId) - else -> Routes.SpendingIntroHw(walletId) -} - -fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) - fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) fun NavController.navToActivityDetail(activity: Activity) = navigateTo( @@ -2461,39 +2246,6 @@ sealed interface Routes { @Serializable data object TransferRoot : Routes.DeepLinkable - @Serializable - data object TransferIntro : Routes.DeepLinkable - - @Serializable - data object SpendingIntro : Routes.DeepLinkable - - @Serializable - data class SpendingIntroHw(val walletId: String) : Routes.DeepLinkable - - @Serializable - data object SpendingAmount : Routes.DeepLinkable - - @Serializable - data class SpendingAmountHw(val walletId: String) : Routes.DeepLinkable - - @Serializable - data class SpendingHwSign(val walletId: String) : Routes.InternalOnly - - @Serializable - data object SpendingHwSigned : Routes.InternalOnly - - @Serializable - data object SpendingConfirm : Routes.InternalOnly - - @Serializable - data object SpendingAdvanced : Routes.InternalOnly - - @Serializable - data object TransferLiquidity : Routes.DeepLinkable - - @Serializable - data object SettingUp : Routes.InternalOnly - @Serializable data object SavingsIntro : Routes.DeepLinkable @@ -2512,9 +2264,6 @@ sealed interface Routes { @Serializable data object Funding : Routes.DeepLinkable - @Serializable - data object FundingAdvanced : Routes.DeepLinkable - @Serializable data object ExternalNav : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index b98222b557..cf0fe8b3eb 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -458,10 +458,8 @@ private fun OnboardingNav( } composableWithDefaultTransitions { navBackEntry -> val route = navBackEntry.toRoute() - val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() OnboardingSlidesScreen( currentTab = route.tab, - isGeoBlocked = isGeoBlocked, onAdvancedSetupClick = { startupNavController.navigateTo(StartupRoutes.Advanced) }, onCreateClick = { scope.launch { diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 18c366380e..66640d1037 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -96,7 +96,6 @@ sealed interface Sheet { data object ActivityTagSelector : Sheet data class LnurlAuth(val domain: String, val lnurl: String, val k1: String) : Sheet data object ForceTransfer : Sheet - data class Gift(val code: String, val amount: ULong) : Sheet data object ConnectionClosed : Sheet data object TransferUnavailable : Sheet data class BTCPayConnection( diff --git a/app/src/main/java/to/bitkit/ui/onboarding/OnboardingSlidesScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/OnboardingSlidesScreen.kt index 5777cebb6b..f275a47d36 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/OnboardingSlidesScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/OnboardingSlidesScreen.kt @@ -40,7 +40,6 @@ import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar @@ -54,7 +53,6 @@ private const val PAGE_COUNT = LAST_PAGE_INDEX + 1 @Composable fun OnboardingSlidesScreen( - isGeoBlocked: Boolean, onAdvancedSetupClick: () -> Unit, onCreateClick: () -> Unit, onRestoreClick: () -> Unit, @@ -87,7 +85,6 @@ fun OnboardingSlidesScreen( title = stringResource(R.string.onboarding__slide1_header), titleAccentColor = Colors.Purple, text = stringResource(R.string.onboarding__slide1_text), - disclaimerText = stringResource(R.string.onboarding__slide1_note).takeIf { isGeoBlocked }, modifier = Modifier.testTag("Slide1") ) @@ -183,7 +180,6 @@ fun OnboardingTab( titleAccentColor: Color, text: String, modifier: Modifier = Modifier, - disclaimerText: String? = null, ) { Column( modifier = modifier.fillMaxSize() @@ -212,10 +208,6 @@ fun OnboardingTab( color = Colors.White64, minLines = 3, ) - disclaimerText?.let { - VerticalSpacer(4.dp) - Footnote(text = it, color = Colors.White32) - } } } } @@ -229,7 +221,6 @@ private fun OnboardingViewPreview() { onAdvancedSetupClick = {}, onCreateClick = {}, onRestoreClick = {}, - isGeoBlocked = true ) } } @@ -243,21 +234,6 @@ private fun OnboardingViewPreview2() { onAdvancedSetupClick = {}, onCreateClick = {}, onRestoreClick = {}, - isGeoBlocked = true - ) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun OnboardingViewPreview3() { - AppThemeSurface { - OnboardingSlidesScreen( - currentTab = 1, - onAdvancedSetupClick = {}, - onCreateClick = {}, - onRestoreClick = {}, - isGeoBlocked = false ) } } @@ -271,7 +247,6 @@ private fun OnboardingViewPreview4() { onAdvancedSetupClick = {}, onCreateClick = {}, onRestoreClick = {}, - isGeoBlocked = false ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt deleted file mode 100644 index dea3a04521..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingAdvancedScreen.kt +++ /dev/null @@ -1,81 +0,0 @@ -package to.bitkit.ui.screens.transfer - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -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 to.bitkit.R -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.RectangleButton -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent - -@Composable -fun FundingAdvancedScreen( - onLnurl: () -> Unit = {}, - onManual: () -> Unit = {}, - onBackClick: () -> Unit = {}, -) { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.lightning__funding_advanced__nav_title), - onBackClick = onBackClick, - actions = { DrawerNavIcon() }, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(32.dp)) - Display( - text = stringResource( - R.string.lightning__funding_advanced__title - ).withAccent(accentColor = Colors.Purple) - ) - Spacer(modifier = Modifier.height(8.dp)) - BodyM(text = stringResource(R.string.lightning__funding_advanced__text), color = Colors.White64) - Spacer(modifier = Modifier.height(32.dp)) - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - RectangleButton( - label = stringResource(R.string.lightning__funding_advanced__button1), - icon = R.drawable.ic_scan, - iconTint = Colors.Purple, - onClick = onLnurl, - ) - RectangleButton( - label = stringResource(R.string.lightning__funding_advanced__button2), - icon = R.drawable.ic_pencil_full, - iconTint = Colors.Purple, - iconSize = 13.37.dp, - onClick = onManual, - modifier = Modifier.testTag("FundManual") - ) - } - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun FundingAdvancedScreenPreview() { - AppThemeSurface { - FundingAdvancedScreen() - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt index 1ff681448e..24a4810270 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/FundingScreen.kt @@ -1,181 +1,117 @@ package to.bitkit.ui.screens.transfer import android.content.Intent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue 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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.net.toUri import to.bitkit.R -import to.bitkit.env.Defaults import to.bitkit.env.Env -import to.bitkit.ui.LocalBalances import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.RectangleButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent @Composable fun FundingScreen( - isGeoBlocked: Boolean, - onTransfer: () -> Unit = {}, - onFund: () -> Unit = {}, onManual: () -> Unit = {}, onBackClick: () -> Unit = {}, ) { - val balances = LocalBalances.current - val canTransfer = remember(balances.channelFundableBalance) { - balances.channelFundableBalance >= Defaults.recommendedBaseFee - } - var showNoFundsAlert by remember { mutableStateOf(false) } - + val context = LocalContext.current ScreenColumn { AppTopBar( titleText = stringResource(R.string.lightning__funding__nav_title), onBackClick = onBackClick, actions = { DrawerNavIcon() }, ) - Column( + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.lightning__funding__title).withAccent(accentColor = Colors.Purple), + modifier = Modifier.padding(horizontal = 16.dp), + ) + VerticalSpacer(8.dp) + BodyM( + text = stringResource(R.string.lightning__transfer_unavailable__text), + color = Colors.White64, + modifier = Modifier.padding(horizontal = 16.dp), + ) + VerticalSpacer(32.dp) + RectangleButton( + label = stringResource(R.string.lightning__funding_advanced__button2), + icon = R.drawable.ic_pencil_full, + iconTint = Colors.Purple, + iconSize = 13.37.dp, + onClick = onManual, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(32.dp)) - Display(text = stringResource(R.string.lightning__funding__title).withAccent(accentColor = Colors.Purple)) - Spacer(modifier = Modifier.height(8.dp)) - - val text = if (isGeoBlocked) { - stringResource(R.string.lightning__transfer_unavailable__text) - } else { - stringResource(R.string.lightning__funding__text) - } - BodyM(text = text, color = Colors.White64) - - Spacer(modifier = Modifier.height(32.dp)) - - val manualButton = @Composable { - RectangleButton( - label = stringResource(R.string.lightning__funding_advanced__button2), - icon = R.drawable.ic_pencil_full, - iconTint = Colors.Purple, - iconSize = 13.37.dp, - onClick = onManual, - modifier = Modifier.testTag("FundManual") - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (isGeoBlocked) manualButton() - Box { - RectangleButton( - label = stringResource(R.string.lightning__funding__button1), - icon = R.drawable.ic_transfer, - iconTint = Colors.Purple, - enabled = canTransfer && !isGeoBlocked, - onClick = onTransfer, - modifier = Modifier.testTag("FundTransfer") - ) - Box( - modifier = Modifier - .matchParentSize() - .clickableAlpha( - pressedAlpha = 1f, - enabled = balances.channelFundableBalance == 0uL && !isGeoBlocked, - onClick = { showNoFundsAlert = true }, - ) - .testTag("FundTransfer") - ) - } - if (isGeoBlocked) { - RectangleButton( - label = stringResource(R.string.lightning__funding__button_hw), - icon = R.drawable.ic_device_tablet_speaker, - iconTint = Colors.Purple, - iconSize = 16.25.dp, - enabled = false, - onClick = {}, - modifier = Modifier.testTag("FundHardware") - ) - } - RectangleButton( - label = stringResource(R.string.lightning__funding__button2), - icon = R.drawable.ic_qr_purple, - iconTint = Colors.Purple, - iconSize = 13.75.dp, - enabled = !isGeoBlocked, - onClick = onFund, - modifier = Modifier.testTag("FundReceive") - ) - if (!isGeoBlocked) manualButton() - } - } - if (isGeoBlocked) { - val context = LocalContext.current - FillHeight() - SecondaryButton( - text = stringResource(R.string.common__learn_more), - onClick = { - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Env.BLOCKTANK_SUNSET_URL.toUri())) } - }, - modifier = Modifier - .padding(horizontal = 16.dp) - .testTag("FundLearnMore") - ) - VerticalSpacer(16.dp) - } - if (showNoFundsAlert) { - AppAlertDialog( - title = stringResource(R.string.lightning__no_funds__title), - text = stringResource(R.string.lightning__no_funds__description), - confirmText = stringResource(R.string.lightning__no_funds__fund_wallet), - confirmTextColor = Colors.Purple, - onConfirm = { - showNoFundsAlert = false - onFund() - }, - onDismiss = { showNoFundsAlert = false }, - ) - } + .testTag("FundManual") + ) + UnavailableOption(R.string.lightning__funding__button1, R.drawable.ic_transfer, 20.dp, "FundTransfer") + UnavailableOption( + R.string.lightning__funding__button_hw, + R.drawable.ic_device_tablet_speaker, + 16.25.dp, + "FundHardware" + ) + UnavailableOption(R.string.lightning__funding__button2, R.drawable.ic_qr_purple, 13.75.dp, "FundReceive") + FillHeight() + SecondaryButton( + text = stringResource(R.string.common__learn_more), + onClick = { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Env.BLOCKTANK_SUNSET_URL.toUri())) } + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("FundLearnMore") + ) + VerticalSpacer(16.dp) } } -@Preview(showSystemUi = true) @Composable -private fun FundingScreenPreview() { - AppThemeSurface { - FundingScreen(isGeoBlocked = false) - } +private fun UnavailableOption( + @StringRes label: Int, + @DrawableRes icon: Int, + iconSize: Dp, + testTag: String, +) { + VerticalSpacer(8.dp) + RectangleButton( + label = stringResource(label), + icon = icon, + iconTint = Colors.Purple, + iconSize = iconSize, + enabled = false, + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag(testTag) + ) } @Preview(showSystemUi = true) @Composable -private fun FundingScreenGeoBlockedPreview() { +private fun Preview() { AppThemeSurface { - FundingScreen(isGeoBlocked = true) + FundingScreen() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/LiquidityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/LiquidityScreen.kt deleted file mode 100644 index aa4db0cf31..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/LiquidityScreen.kt +++ /dev/null @@ -1,115 +0,0 @@ -package to.bitkit.ui.screens.transfer - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -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.BodyM -import to.bitkit.ui.components.BodyMB -import to.bitkit.ui.components.ChannelStatusUi -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.LightningChannel -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.transferViewModel -import to.bitkit.ui.utils.withAccent - -@Composable -fun LiquidityScreen( - onContinueClick: () -> Unit = {}, - onBackClick: () -> Unit = {}, -) { - val transfer = transferViewModel ?: return - val state by transfer.spendingUiState.collectAsStateWithLifecycle() - if (state.feeSat == 0uL) return - - val channelSize = (state.clientBalanceSat + state.lspBalanceSat).toLong() - val localBalance = state.clientBalanceSat.toLong() - val remoteBalance = state.lspBalanceSat.toLong() - - LiquidityScreen( - channelSize = channelSize, - localBalance = localBalance, - remoteBalance = remoteBalance, - onBackClick = onBackClick, - onContinueClick = onContinueClick, - ) -} - -@Composable -private fun LiquidityScreen( - channelSize: Long, - localBalance: Long, - remoteBalance: Long, - onBackClick: () -> Unit = {}, - onContinueClick: () -> Unit = {}, -) { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.lightning__transfer__nav_title), - onBackClick = onBackClick, - actions = { DrawerNavIcon() }, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(32.dp)) - Display(text = stringResource(R.string.lightning__liquidity__title).withAccent(accentColor = Colors.Purple)) - Spacer(modifier = Modifier.height(8.dp)) - BodyM(text = stringResource(R.string.lightning__liquidity__text), color = Colors.White64) - - Spacer(modifier = Modifier.weight(1f)) - - BodyMB(text = stringResource(R.string.lightning__liquidity__label)) - Spacer(modifier = Modifier.height(16.dp)) - - LightningChannel( - capacity = channelSize, - localBalance = localBalance, - remoteBalance = remoteBalance, - status = ChannelStatusUi.OPEN, - showLabels = true, - ) - - Spacer(modifier = Modifier.height(32.dp)) - PrimaryButton( - text = stringResource(R.string.common__understood), - onClick = onContinueClick, - modifier = Modifier.testTag("LiquidityContinue") - ) - Spacer(modifier = Modifier.height(16.dp)) - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun LiquidityScreenPreview() { - AppThemeSurface { - val channelSize = 200_000L - val localBalance = 50_000L - val remoteBalance = channelSize - localBalance - - LiquidityScreen( - channelSize = channelSize, - localBalance = localBalance, - remoteBalance = remoteBalance, - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt deleted file mode 100644 index 39297d57db..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt +++ /dev/null @@ -1,222 +0,0 @@ -package to.bitkit.ui.screens.transfer - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.synonym.bitkitcore.regtestMine -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.delay -import org.lightningdevkit.ldknode.Network -import to.bitkit.R -import to.bitkit.env.Env -import to.bitkit.ui.appViewModel -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.screens.transfer.components.ProgressSteps -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.localizedRandom -import to.bitkit.ui.utils.withAccent -import to.bitkit.ui.utils.withAccentBoldBright -import to.bitkit.utils.Logger -import to.bitkit.viewmodels.TransferViewModel - -private const val TAG = "SettingUpScreen" - -@Composable -fun SettingUpScreen( - viewModel: TransferViewModel, - onContinueClick: () -> Unit = {}, -) { - val app = appViewModel ?: return - val lightningSetupStep by viewModel.lightningSetupStep.collectAsState() - - LaunchedEffect(Unit) { - Logger.debug("SettingUp view appeared - TransferViewModel is handling order updates") - - // Auto-mine a block on regtest after a 5-seconds delay - if (Env.network == Network.REGTEST) { - delay(5000) - - runCatching { - Logger.debug("Auto-mining a block", context = TAG) - regtestMine(1u) - Logger.debug("Successfully mined a block", context = TAG) - }.onFailure { - Logger.error("Failed to mine block", it, context = TAG) - } - } - } - - // Effect to disable new transaction sheet for channel purchase - DisposableEffect(Unit) { - app.enabledTransactionSheet(false) - onDispose { - app.enabledTransactionSheet(true) - } - } - - SettingUpScreen( - lightningSetupStep = lightningSetupStep, - onContinueClick = { - viewModel.resetSpendingState() - onContinueClick() - }, - ) -} - -@Composable -private fun SettingUpScreen( - lightningSetupStep: Int, - onContinueClick: () -> Unit = {}, -) { - val inProgress = lightningSetupStep < 3 - ScreenColumn( - modifier = Modifier.testTag(if (inProgress) "LightningSettingUp" else "TransferSuccess") - ) { - AppTopBar( - titleText = when { - inProgress -> stringResource(R.string.lightning__transfer__nav_title) - else -> stringResource(R.string.lightning__transfer_success__nav_title) - }, - onBackClick = null, - actions = { DrawerNavIcon() }, - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(12.dp)) - if (inProgress) { - Display( - text = stringResource(R.string.lightning__savings_progress__title) - .withAccent(accentColor = Colors.Purple), - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(8.dp)) - BodyM( - text = stringResource(R.string.lightning__setting_up_text).withAccentBoldBright(), - color = Colors.White64, - ) - } else { - Display( - text = stringResource(R.string.lightning__transfer_success__title_spending) - .withAccent(accentColor = Colors.Purple), - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(8.dp)) - BodyM( - text = stringResource(R.string.lightning__transfer_success__text_spending), - color = Colors.White64, - ) - } - Spacer(modifier = Modifier.height(28.dp)) - if (inProgress) { - TransferAnimationView( - largeCircleRes = R.drawable.ln_sync_large, - smallCircleRes = R.drawable.ln_sync_small, - modifier = Modifier.weight(1f) - ) - VerticalSpacer(16.dp) - val steps = persistentListOf( - stringResource(R.string.lightning__setting_up_step1), - stringResource(R.string.lightning__setting_up_step2), - stringResource(R.string.lightning__setting_up_step3), - stringResource(R.string.lightning__setting_up_step4), - ) - ProgressSteps( - steps = steps, - activeStepIndex = lightningSetupStep, - modifier = Modifier.align(alignment = Alignment.CenterHorizontally) - ) - Spacer(modifier = Modifier.height(16.dp)) - } else { - Image( - painter = painterResource(id = R.drawable.check), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .weight(1f) - .padding(16.dp) - ) - } - - val randomOkText = localizedRandom(R.string.common__ok_random) - PrimaryButton( - text = if (inProgress) { - stringResource(R.string.lightning__setting_up_button) - } else { - randomOkText - }, - onClick = onContinueClick, - modifier = Modifier.testTag("TransferSuccess-button") - ) - Spacer(modifier = Modifier.height(16.dp)) - } - } -} - -@Preview(name = "Progress", showSystemUi = true, showBackground = true) -@Composable -private fun SettingUpScreenProgressPreview() { - AppThemeSurface { - SettingUpScreen( - lightningSetupStep = 2, - ) - } -} - -@Preview(name = "Progress", showSystemUi = true, showBackground = true, device = NEXUS_5) -@Composable -private fun SettingUpScreenProgressSmallPreview() { - AppThemeSurface { - SettingUpScreen( - lightningSetupStep = 2, - ) - } -} - -@Preview(name = "Success", showSystemUi = true, showBackground = true) -@Composable -private fun SettingUpScreenSuccessPreview() { - AppThemeSurface { - SettingUpScreen( - lightningSetupStep = 3, - ) - } -} - -@Preview(name = "Success", showSystemUi = true, showBackground = true, device = NEXUS_5) -@Composable -private fun SettingUpScreenSuccessSmallPreview() { - AppThemeSurface { - SettingUpScreen( - lightningSetupStep = 3, - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt deleted file mode 100644 index 01444c3a9e..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ /dev/null @@ -1,338 +0,0 @@ -package to.bitkit.ui.screens.transfer - -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.padding -import androidx.compose.foundation.layout.requiredHeight -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 -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.models.Toast -import to.bitkit.models.formatToModernDisplay -import to.bitkit.repositories.CurrencyState -import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.appViewModel -import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.HorizontalSpacer -import to.bitkit.ui.components.MoneySSB -import to.bitkit.ui.components.NumberPad -import to.bitkit.ui.components.NumberPadActionButton -import to.bitkit.ui.components.NumberPadTextField -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.AmountInputEffect -import to.bitkit.viewmodels.AmountInputViewModel -import to.bitkit.viewmodels.TransferEffect -import to.bitkit.viewmodels.TransferToSpendingUiState -import to.bitkit.viewmodels.TransferValues -import to.bitkit.viewmodels.TransferViewModel -import to.bitkit.viewmodels.previewAmountInputViewModel - -@Suppress("ViewModelForwarding") -@Composable -fun SpendingAdvancedScreen( - viewModel: TransferViewModel, - onBackClick: () -> Unit = {}, - onQuoteReady: () -> Unit = {}, - currencies: CurrencyState = LocalCurrencies.current, - amountInputViewModel: AmountInputViewModel = hiltViewModel(), -) { - val currentOnQuoteReady by rememberUpdatedState(onQuoteReady) - val app = appViewModel ?: return - val resources = LocalResources.current - val state by viewModel.spendingUiState.collectAsStateWithLifecycle() - if (state.feeSat == 0uL) return - val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - var isLoading by remember { mutableStateOf(false) } - - val transferValues by viewModel.transferValues.collectAsStateWithLifecycle() - val currentMaxLspBalance by rememberUpdatedState(transferValues.maxLspBalance) - val currentCurrencies by rememberUpdatedState(currencies) - - LaunchedEffect(state.clientBalanceSat) { - viewModel.updateAdvancedTransferValues(state.clientBalanceSat) - } - - LaunchedEffect(amountUiState.sats) { - viewModel.onReceivingAmountChange(amountUiState.sats) - } - - LaunchedEffect(transferValues.maxLspBalance) { - amountInputViewModel.applyMaxLspBalance( - maxLspBalance = transferValues.maxLspBalance.toLong(), - enteredSats = amountUiState.sats, - currencies = currentCurrencies, - ) - } - - LaunchedEffect(Unit) { - viewModel.transferEffects.collect { effect -> - when (effect) { - TransferEffect.OnQuoteReady -> currentOnQuoteReady() - is TransferEffect.ToastException -> { - isLoading = false - app.toast(effect.e) - } - - is TransferEffect.ToastError -> { - isLoading = false - app.toast( - type = Toast.ToastType.ERROR, - title = effect.title, - description = effect.description, - ) - } - - else -> Unit - } - } - } - - LaunchedEffect(Unit) { - amountInputViewModel.effect.collect { - when (it) { - AmountInputEffect.MaxExceeded -> { - amountInputViewModel.setSats(currentMaxLspBalance.toLong(), currentCurrencies) - app.toast( - type = Toast.ToastType.WARNING, - title = resources.getString(R.string.lightning__spending_advanced__error_max__title), - description = resources.getString(R.string.lightning__spending_advanced__error_max__description) - .replace("{amount}", currentMaxLspBalance.formatToModernDisplay()), - visibilityTime = Toast.VISIBILITY_TIME_SHORT, - ) - } - } - } - } - - val isInRange = transferValues.let { - val amount = amountUiState.sats.toULong() - amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance - } - val isValid = isInRange && state.canAfford(state.clientBalanceSat) - - Content( - uiState = state, - transferValues = transferValues, - isValid = isValid, - isLoading = isLoading || state.isLoading, - amountInputViewModel = amountInputViewModel, - currencies = currencies, - onBack = onBackClick, - onContinue = { - isLoading = true - viewModel.onSpendingAdvancedContinue(amountUiState.sats) - }, - ) -} - -/** - * Settling the max can land it below what is already entered, so the amount comes down with it - * rather than leaving a capacity that no longer exists selected. - */ -private fun AmountInputViewModel.applyMaxLspBalance( - maxLspBalance: Long, - enteredSats: Long, - currencies: CurrencyState, -) { - setMaxAmount(maxLspBalance) - if (maxLspBalance in 1.. Unit, - onContinue: () -> Unit, - currencies: CurrencyState = LocalCurrencies.current, -) { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.lightning__transfer__nav_title), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - .testTag("SpendingAdvanced") - ) { - VerticalSpacer(32.dp) - - Display( - text = stringResource(R.string.lightning__spending_advanced__title) - .withAccent(accentColor = Colors.Purple), - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(32.dp) - - NumberPadTextField( - viewModel = amountInputViewModel, - currencies = currencies, - showSecondaryField = false, - modifier = Modifier - .fillMaxWidth() - .testTag("SpendingAdvancedNumberField") - ) - - VerticalSpacer(16.dp) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.requiredHeight(20.dp), - ) { - Caption13Up( - text = stringResource(R.string.lightning__spending_advanced__fee), - color = Colors.White64, - ) - HorizontalSpacer(8.dp) - uiState.feeEstimate?.let { - MoneySSB(it, showSymbol = true) - } ?: run { - Caption13Up(text = "—", color = Colors.White64) - } - } - - FillHeight() - - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) { - NumberPadActionButton( - text = stringResource(R.string.common__min), - color = Colors.Purple, - onClick = { amountInputViewModel.setSats(transferValues.minLspBalance.toLong(), currencies) }, - modifier = Modifier.testTag("SpendingAdvancedMin") - ) - NumberPadActionButton( - text = stringResource(R.string.common__default), - color = Colors.Purple, - onClick = { amountInputViewModel.setSats(transferValues.defaultLspBalance.toLong(), currencies) }, - modifier = Modifier.testTag("SpendingAdvancedDefault") - ) - NumberPadActionButton( - text = stringResource(R.string.common__max), - color = Colors.Purple, - onClick = { amountInputViewModel.setSats(transferValues.maxLspBalance.toLong(), currencies) }, - modifier = Modifier.testTag("SpendingAdvancedMax") - ) - } - - HorizontalDivider() - VerticalSpacer(16.dp) - - NumberPad( - viewModel = amountInputViewModel, - enabled = !isLoading, - currencies = currencies, - ) - - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onContinue, - enabled = !isLoading && isValid, - isLoading = isLoading, - modifier = Modifier.testTag("SpendingAdvancedContinue") - ) - - VerticalSpacer(16.dp) - } - } -} - -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, device = "id:pixel_9_pro_xl", name = "Large") -@Preview(showSystemUi = true, device = NEXUS_5, name = "Small") -@Composable -private fun Preview() { - AppThemeSurface { - Content( - uiState = TransferToSpendingUiState( - clientBalanceSat = 100_000uL, - receivingAmount = 55_000L, - feeEstimate = 2_500L, - ), - transferValues = TransferValues( - defaultLspBalance = 50_000u, - minLspBalance = 10_000u, - maxLspBalance = 90_000u, - ), - isValid = true, - amountInputViewModel = previewAmountInputViewModel(), - isLoading = false, - onBack = {}, - onContinue = {}, - ) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun PreviewLoading() { - AppThemeSurface { - Content( - uiState = TransferToSpendingUiState( - clientBalanceSat = 50_000uL, - receivingAmount = 20_000L, - feeEstimate = null, - isLoading = true, - ), - transferValues = TransferValues( - defaultLspBalance = 25_000u, - minLspBalance = 10_000u, - maxLspBalance = 40_000u, - ), - isValid = true, - amountInputViewModel = previewAmountInputViewModel(), - isLoading = true, - onBack = {}, - onContinue = {}, - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt deleted file mode 100644 index b3706fdd30..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ /dev/null @@ -1,309 +0,0 @@ -package to.bitkit.ui.screens.transfer - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 -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.models.formatToModernDisplay -import to.bitkit.repositories.CurrencyState -import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.components.ConnectionIssuesView -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.FillWidth -import to.bitkit.ui.components.MoneySSB -import to.bitkit.ui.components.NumberPad -import to.bitkit.ui.components.NumberPadActionButton -import to.bitkit.ui.components.NumberPadTextField -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.SyncNodeView -import to.bitkit.ui.components.Text13Up -import to.bitkit.ui.components.UnitButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.AmountInputEffect -import to.bitkit.viewmodels.AmountInputViewModel -import to.bitkit.viewmodels.TransferEffect -import to.bitkit.viewmodels.TransferToSpendingUiState -import to.bitkit.viewmodels.TransferViewModel -import to.bitkit.viewmodels.previewAmountInputViewModel - -@Suppress("ViewModelForwarding") -@Composable -fun SpendingAmountScreen( - viewModel: TransferViewModel, - isOffline: Boolean, - onBackClick: () -> Unit = {}, - onQuoteReady: () -> Unit = {}, - toastException: (Throwable) -> Unit, - toast: (title: String, description: String) -> Unit, - currencies: CurrencyState = LocalCurrencies.current, - amountInputViewModel: AmountInputViewModel = hiltViewModel(), -) { - val uiState by viewModel.spendingUiState.collectAsStateWithLifecycle() - val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle() - val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - val resources = LocalResources.current - val currentMaxAllowedToSend by rememberUpdatedState(uiState.maxAllowedToSend) - val currentCurrencies by rememberUpdatedState(currencies) - - LaunchedEffect(isOffline) { - viewModel.updateLimits() - } - - LaunchedEffect(Unit) { - viewModel.transferEffects.collect { effect -> - when (effect) { - TransferEffect.OnQuoteReady -> onQuoteReady() - is TransferEffect.ToastError -> toast(effect.title, effect.description) - is TransferEffect.ToastException -> toastException(effect.e) - else -> Unit - } - } - } - - LaunchedEffect(Unit) { - amountInputViewModel.effect.collect { - when (it) { - AmountInputEffect.MaxExceeded -> { - amountInputViewModel.setSats(currentMaxAllowedToSend, currentCurrencies) - toast( - resources.getString(R.string.lightning__spending_amount__error_max__title), - resources.getString(R.string.lightning__spending_amount__error_max__description) - .replace("{amount}", currentMaxAllowedToSend.formatToModernDisplay()), - ) - } - } - } - } - - Box { - Content( - isNodeRunning = isNodeRunning, - uiState = uiState, - amountInputViewModel = amountInputViewModel, - currencies = currencies, - onBackClick = onBackClick, - onClickQuarter = { - amountInputViewModel.setSats(uiState.quarterAmount, currencies) - }, - onClickMaxAmount = { - amountInputViewModel.setSats(uiState.maxAllowedToSend, currencies) - }, - onConfirmAmount = { viewModel.onConfirmAmount(amountUiState.sats) }, - ) - AnimatedVisibility( - visible = isOffline, - enter = fadeIn(), - exit = fadeOut(), - ) { - ConnectionIssuesView( - titleText = stringResource(R.string.lightning__transfer__nav_title), - modifier = Modifier.statusBarsPadding() - ) - } - } -} - -@Suppress("ViewModelForwarding") -@Composable -private fun Content( - isNodeRunning: Boolean, - uiState: TransferToSpendingUiState, - amountInputViewModel: AmountInputViewModel, - onBackClick: () -> Unit, - onClickQuarter: () -> Unit, - onClickMaxAmount: () -> Unit, - onConfirmAmount: () -> Unit, - currencies: CurrencyState = LocalCurrencies.current, -) { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.lightning__transfer__nav_title), - onBackClick = onBackClick, - actions = { DrawerNavIcon() }, - ) - - if (isNodeRunning) { - SpendingAmountNodeRunning( - uiState = uiState, - amountInputViewModel = amountInputViewModel, - currencies = currencies, - onClickQuarter = onClickQuarter, - onClickMaxAmount = onClickMaxAmount, - onConfirmAmount = onConfirmAmount, - ) - } else { - SyncNodeView( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) - } - } -} - -@Suppress("ViewModelForwarding") -@Composable -private fun SpendingAmountNodeRunning( - uiState: TransferToSpendingUiState, - amountInputViewModel: AmountInputViewModel, - currencies: CurrencyState, - onClickQuarter: () -> Unit, - onClickMaxAmount: () -> Unit, - onConfirmAmount: () -> Unit, -) { - LaunchedEffect(uiState.maxAllowedToSend) { - amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend) - } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - .testTag("SpendingAmount") - ) { - val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - - VerticalSpacer(32.dp) - - Display( - text = stringResource(R.string.lightning__spending_amount__title) - .withAccent(accentColor = Colors.Purple), - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(32.dp) - - NumberPadTextField( - viewModel = amountInputViewModel, - currencies = currencies, - showSecondaryField = false, - modifier = Modifier - .fillMaxWidth() - .testTag("SpendingAmountNumberField") - ) - - FillHeight() - - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.testTag("SendAmountNumberPad") - ) { - Column { - Text13Up( - text = stringResource(R.string.wallet__send_available), - color = Colors.White64, - modifier = Modifier.testTag("SpendingAmountAvailable") - ) - VerticalSpacer(8.dp) - MoneySSB( - sats = uiState.balanceAfterFee, - showSymbol = true, - modifier = Modifier.testTag("SpendingAmountUnit") - ) - } - FillWidth() - UnitButton( - color = Colors.Purple, - onClick = { amountInputViewModel.switchUnit(currencies) }, - modifier = Modifier.testTag("SpendingNumberPadUnit") - ) - NumberPadActionButton( - text = stringResource(R.string.lightning__spending_amount__quarter), - color = Colors.Purple, - onClick = onClickQuarter, - modifier = Modifier.testTag("SpendingAmountQuarter") - ) - NumberPadActionButton( - text = stringResource(R.string.common__max), - color = Colors.Purple, - onClick = onClickMaxAmount, - modifier = Modifier.testTag("SpendingAmountMax") - ) - } - - HorizontalDivider(modifier = Modifier.padding(top = 12.dp)) - - NumberPad( - viewModel = amountInputViewModel, - currencies = currencies, - enabled = !uiState.isLoading, - ) - - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onConfirmAmount, - enabled = !uiState.isLoading && amountUiState.sats <= uiState.maxAllowedToSend, - isLoading = uiState.isLoading, - modifier = Modifier.testTag("SpendingAmountContinue") - ) - - VerticalSpacer(16.dp) - } -} - -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, device = "id:pixel_9_pro_xl", name = "Large") -@Preview(showSystemUi = true, device = NEXUS_5, name = "Small") -@Composable -private fun Preview() { - AppThemeSurface { - Content( - isNodeRunning = true, - uiState = TransferToSpendingUiState(maxAllowedToSend = 158_234, balanceAfterFee = 158_234), - amountInputViewModel = previewAmountInputViewModel(), - currencies = CurrencyState(), - onBackClick = {}, - onClickQuarter = {}, - onClickMaxAmount = {}, - onConfirmAmount = {}, - ) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun PreviewInitializing() { - AppThemeSurface { - Content( - isNodeRunning = false, - uiState = TransferToSpendingUiState(), - amountInputViewModel = previewAmountInputViewModel(), - currencies = CurrencyState(), - onBackClick = {}, - onClickQuarter = {}, - onClickMaxAmount = {}, - onConfirmAmount = {}, - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt deleted file mode 100644 index 9567fdbbd1..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ /dev/null @@ -1,392 +0,0 @@ -package to.bitkit.ui.screens.transfer - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -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.models.safe -import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.ChannelStatusUi -import to.bitkit.ui.components.ConnectionIssuesView -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FeeInfo -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.LightningChannel -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.SwipeToConfirm -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.settings.SettingsSwitchRow -import to.bitkit.ui.openNotificationSettings -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.theme.AppSwitchDefaults -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.RequestNotificationPermissions -import to.bitkit.ui.utils.rememberNotificationToggleClick -import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.SettingsViewModel -import to.bitkit.viewmodels.TransferToSpendingUiState -import to.bitkit.viewmodels.TransferViewModel - -private const val SWIPE_ROTATION_DEGREES = 14f - -@Composable -fun SpendingConfirmScreen( - viewModel: TransferViewModel, - isOffline: Boolean, - onBackClick: () -> Unit = {}, - onCloseClick: () -> Unit = {}, - onLearnMoreClick: () -> Unit = {}, - onAdvancedClick: () -> Unit = {}, - settingsViewModel: SettingsViewModel = hiltViewModel(), -) { - val context = LocalContext.current - - val state by viewModel.spendingUiState.collectAsStateWithLifecycle() - - if (state.feeSat == 0uL) { - onCloseClick() - return - } - val isAdvanced = state.isAdvanced - val miningFeeSats = state.miningFeeSats - val isConfirmFeeReady = state.isConfirmFeeReady - val isConfirmPaying = state.isBusy - - BackHandler(enabled = state.isBusy) {} - - LaunchedEffect(state.feeSat) { - viewModel.prepareSpendingConfirmFunding() - } - - val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() - - RequestNotificationPermissions( - onPermissionChange = { granted -> - settingsViewModel.setNotificationPreference(granted) - }, - showPermissionDialog = false, - ) - - val onNotificationSwitchClick = rememberNotificationToggleClick( - isGranted = notificationsGranted, - onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, - onOpenSystemSettings = { context.openNotificationSettings() }, - ) - - Box { - Content( - onBackClick = { if (!state.isBusy) onBackClick() }, - onLearnMoreClick = { if (!state.isBusy) onLearnMoreClick() }, - onAdvancedClick = { if (!state.isBusy) onAdvancedClick() }, - onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, - onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, - state = state, - miningFeeSats = miningFeeSats, - isConfirmFeeReady = isConfirmFeeReady, - isConfirmPaying = isConfirmPaying, - hasNotificationPermission = notificationsGranted, - onSwitchClick = onNotificationSwitchClick, - isAdvanced = isAdvanced, - ) - AnimatedVisibility( - visible = isOffline, - enter = fadeIn(), - exit = fadeOut(), - ) { - ConnectionIssuesView( - titleText = stringResource(R.string.lightning__transfer__nav_title), - modifier = Modifier.statusBarsPadding() - ) - } - } -} - -@Composable -private fun Content( - onBackClick: () -> Unit, - onLearnMoreClick: () -> Unit, - onAdvancedClick: () -> Unit, - onUseDefaultLspBalanceClick: () -> Unit, - onSwitchClick: () -> Unit, - hasNotificationPermission: Boolean, - onTransferToSpendingConfirm: () -> Unit, - state: TransferToSpendingUiState, - miningFeeSats: ULong, - isConfirmFeeReady: Boolean, - isConfirmPaying: Boolean, - isAdvanced: Boolean, -) { - val swipeProgress = remember { mutableFloatStateOf(0f) } - - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.lightning__transfer__nav_title), - onBackClick = onBackClick, - actions = { if (!isConfirmPaying) DrawerNavIcon() }, - ) - Box(modifier = Modifier.fillMaxSize()) { - if (!isAdvanced) { - Image( - painter = painterResource(id = R.drawable.coin_stack_x), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 60.dp) - .align(Alignment.BottomCenter) - .padding(bottom = 76.dp) - .graphicsLayer { rotationZ = swipeProgress.floatValue * SWIPE_ROTATION_DEGREES } - ) - } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - val clientBalance = state.clientBalanceSat - val lspFee = state.feeSat.safe() - clientBalance.safe() - val total = state.confirmLeavingAmountSats - val lspBalance = state.lspBalanceSat - - VerticalSpacer(32.dp) - Display(stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) - VerticalSpacer(8.dp) - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.height(IntrinsicSize.Min) - ) { - FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__network_fee), - amount = miningFeeSats.toLong(), - ) - FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__lsp_fee), - amount = lspFee.toLong(), - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.height(IntrinsicSize.Min) - ) { - FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__amount), - amount = clientBalance.toLong(), - ) - FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__total), - amount = total.toLong(), - ) - } - - if (isAdvanced) { - VerticalSpacer(16.dp) - LightningChannel( - capacity = (clientBalance + lspBalance).toLong(), - localBalance = clientBalance.toLong(), - remoteBalance = lspBalance.toLong(), - status = ChannelStatusUi.OPEN, - showLabels = true, - modifier = Modifier.testTag("SpendingConfirmChannel") - ) - - VerticalSpacer(16.dp) - } - - SettingsSwitchRow( - title = stringResource(R.string.settings__bg__setup), - isChecked = hasNotificationPermission, - colors = AppSwitchDefaults.colorsPurple, - onClick = onSwitchClick, - switchTestTag = "SpendingConfirmNotificationSwitch", - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(16.dp) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - PrimaryButton( - text = stringResource(R.string.common__learn_more), - size = ButtonSize.Small, - fullWidth = false, - onClick = onLearnMoreClick, - modifier = Modifier.testTag("SpendingConfirmMore") - ) - PrimaryButton( - text = stringResource( - if (isAdvanced) R.string.lightning__spending_confirm__default else R.string.common__advanced - ), - size = ButtonSize.Small, - fullWidth = false, - onClick = { - if (isAdvanced) { - onUseDefaultLspBalanceClick() - } else { - onAdvancedClick() - } - }, - modifier = Modifier.testTag( - if (isAdvanced) "SpendingConfirmDefault" else "SpendingConfirmAdvanced" - ) - ) - } - VerticalSpacer(16.dp) - - FillHeight() - - val canConfirm = isConfirmFeeReady && miningFeeSats > 0uL && !isConfirmPaying - SwipeToConfirm( - text = stringResource(R.string.lightning__transfer__swipe), - loading = isConfirmPaying || !isConfirmFeeReady, - color = Colors.Purple, - progress = swipeProgress, - onConfirm = { - if (!canConfirm) return@SwipeToConfirm - onTransferToSpendingConfirm() - }, - ) - VerticalSpacer(16.dp) - } - } - } -} - -@Preview(showSystemUi = true, showBackground = true, name = "Normal screen - Default") -@Composable -private fun Preview() { - AppThemeSurface { - Content( - onBackClick = {}, - onLearnMoreClick = {}, - onAdvancedClick = {}, - onUseDefaultLspBalanceClick = {}, - onTransferToSpendingConfirm = {}, - state = TransferToSpendingUiState( - clientBalanceSat = 500_000uL, - lspBalanceSat = 2_000_000uL, - feeSat = 1_000uL, - - ), - onSwitchClick = {}, - hasNotificationPermission = true, - miningFeeSats = 250uL, - isConfirmFeeReady = true, - isConfirmPaying = false, - isAdvanced = false - ) - } -} - -@Preview(showSystemUi = true, showBackground = true, name = "Normal screen - Advanced") -@Composable -private fun Preview2() { - AppThemeSurface { - Content( - onBackClick = {}, - onLearnMoreClick = {}, - onAdvancedClick = {}, - onUseDefaultLspBalanceClick = {}, - onTransferToSpendingConfirm = {}, - state = TransferToSpendingUiState( - clientBalanceSat = 500_000uL, - lspBalanceSat = 2_000_000uL, - feeSat = 1_000uL, - - ), - onSwitchClick = {}, - hasNotificationPermission = true, - miningFeeSats = 250uL, - isConfirmFeeReady = true, - isConfirmPaying = false, - isAdvanced = true - ) - } -} - -@Preview(showSystemUi = true, showBackground = true, heightDp = 700, name = "Small screen - Normal") -@Composable -private fun Preview3() { - AppThemeSurface { - Content( - onBackClick = {}, - onLearnMoreClick = {}, - onAdvancedClick = {}, - onUseDefaultLspBalanceClick = {}, - onTransferToSpendingConfirm = {}, - state = TransferToSpendingUiState( - clientBalanceSat = 500_000uL, - lspBalanceSat = 2_000_000uL, - feeSat = 1_000uL, - - ), - onSwitchClick = {}, - hasNotificationPermission = false, - miningFeeSats = 250uL, - isConfirmFeeReady = true, - isConfirmPaying = false, - isAdvanced = false - ) - } -} - -@Preview(showSystemUi = true, showBackground = true, heightDp = 700, name = "Small screen - Advanced") -@Composable -private fun Preview4() { - AppThemeSurface { - Content( - onBackClick = {}, - onLearnMoreClick = {}, - onAdvancedClick = {}, - onUseDefaultLspBalanceClick = {}, - onTransferToSpendingConfirm = {}, - state = TransferToSpendingUiState( - clientBalanceSat = 500_000uL, - lspBalanceSat = 2_000_000uL, - feeSat = 1_000uL, - - ), - onSwitchClick = {}, - hasNotificationPermission = true, - miningFeeSats = 250uL, - isConfirmFeeReady = true, - isConfirmPaying = false, - isAdvanced = true - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingIntroScreen.kt deleted file mode 100644 index 7a94031b47..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingIntroScreen.kt +++ /dev/null @@ -1,80 +0,0 @@ -package to.bitkit.ui.screens.transfer - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import to.bitkit.R -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.shared.util.screen -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent - -@Composable -fun SpendingIntroScreen( - onContinueClick: () -> Unit = {}, - onBackClick: () -> Unit = {}, -) { - Box( - contentAlignment = Alignment.TopCenter, - modifier = Modifier - .screen() - ) { - Image( - painter = painterResource(id = R.drawable.coin_stack_x), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .padding(top = 130.dp) - .fillMaxWidth() - ) - AppTopBar( - titleText = stringResource(R.string.lightning__transfer__nav_title), - onBackClick = onBackClick, - actions = { DrawerNavIcon() }, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp) - .align(Alignment.BottomCenter) - ) { - Display(stringResource(R.string.lightning__spending_intro__title).withAccent(accentColor = Colors.Purple)) - Spacer(modifier = Modifier.height(8.dp)) - BodyM(stringResource(R.string.lightning__spending_intro__text), color = Colors.White64) - - Spacer(modifier = Modifier.height(32.dp)) - PrimaryButton( - text = stringResource(R.string.lightning__spending_intro__button), - onClick = onContinueClick, - modifier = Modifier.testTag("SpendingIntro-button") - ) - Spacer(modifier = Modifier.height(16.dp)) - } - } -} - -@Preview(showSystemUi = true, showBackground = true) -@Composable -private fun SpendingIntroScreenPreview() { - AppThemeSurface { - SpendingIntroScreen() - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/TransferIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferIntroScreen.kt deleted file mode 100644 index 45192970ba..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/TransferIntroScreen.kt +++ /dev/null @@ -1,85 +0,0 @@ -package to.bitkit.ui.screens.transfer - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import to.bitkit.R -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent - -@Composable -fun TransferIntroScreen( - onContinueClick: () -> Unit = {}, - onBackClick: () -> Unit, -) { - Box( - contentAlignment = Alignment.TopCenter, - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .systemBarsPadding() - ) { - Image( - painter = painterResource(id = R.drawable.lightning), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .padding(top = 130.dp) - .fillMaxWidth() - ) - AppTopBar( - titleText = null, - onBackClick = onBackClick, - actions = { DrawerNavIcon() }, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp) - .align(Alignment.BottomCenter) - ) { - Display(stringResource(R.string.lightning__transfer_intro__title).withAccent(accentColor = Colors.Purple)) - Spacer(modifier = Modifier.height(8.dp)) - BodyM(stringResource(R.string.lightning__transfer_intro__text), color = Colors.White64) - - Spacer(modifier = Modifier.height(32.dp)) - PrimaryButton( - text = stringResource(R.string.lightning__transfer_intro__button), - onClick = onContinueClick, - modifier = Modifier.testTag("TransferIntro-button") - ) - Spacer(modifier = Modifier.height(16.dp)) - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview() { - AppThemeSurface { - TransferIntroScreen(onBackClick = {}) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt deleted file mode 100644 index 8ffd7083cc..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt +++ /dev/null @@ -1,293 +0,0 @@ -package to.bitkit.ui.screens.transfer.hardware - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 -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.models.Toast -import to.bitkit.models.formatToModernDisplay -import to.bitkit.repositories.CurrencyState -import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.components.ConnectionIssuesView -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.FillWidth -import to.bitkit.ui.components.MoneySSB -import to.bitkit.ui.components.NumberPad -import to.bitkit.ui.components.NumberPadActionButton -import to.bitkit.ui.components.NumberPadTextField -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.SyncNodeView -import to.bitkit.ui.components.Text13Up -import to.bitkit.ui.components.UnitButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.AmountInputEffect -import to.bitkit.viewmodels.AmountInputViewModel -import to.bitkit.viewmodels.TransferEffect -import to.bitkit.viewmodels.TransferToSpendingUiState -import to.bitkit.viewmodels.TransferViewModel -import to.bitkit.viewmodels.previewAmountInputViewModel - -@Suppress("ViewModelForwarding") -@Composable -fun SpendingAmountHwScreen( - walletId: String, - viewModel: TransferViewModel, - isOffline: Boolean, - onBackClick: () -> Unit = {}, - onQuoteReady: () -> Unit = {}, - currencies: CurrencyState = LocalCurrencies.current, - amountInputViewModel: AmountInputViewModel = hiltViewModel(), -) { - val uiState by viewModel.spendingUiState.collectAsStateWithLifecycle() - val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle() - val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - val resources = LocalResources.current - val currentMaxAllowedToSend by rememberUpdatedState(uiState.maxAllowedToSend) - val currentCurrencies by rememberUpdatedState(currencies) - - LaunchedEffect(walletId, isOffline) { - viewModel.updateHwLimits(walletId) - } - - LaunchedEffect(Unit) { - viewModel.transferEffects.collect { effect -> - when (effect) { - TransferEffect.OnQuoteReady -> onQuoteReady() - is TransferEffect.ToastError -> ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = effect.title, - description = effect.description, - ) - is TransferEffect.ToastException -> ToastEventBus.send(effect.e) - else -> Unit - } - } - } - - LaunchedEffect(Unit) { - amountInputViewModel.effect.collect { - when (it) { - AmountInputEffect.MaxExceeded -> { - amountInputViewModel.setSats(currentMaxAllowedToSend, currentCurrencies) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = resources.getString(R.string.lightning__spending_amount__error_max__title), - description = resources.getString(R.string.lightning__spending_amount__error_max__description) - .replace("{amount}", currentMaxAllowedToSend.formatToModernDisplay()), - ) - } - } - } - } - - Box { - Content( - isNodeRunning = isNodeRunning, - uiState = uiState, - amountInputViewModel = amountInputViewModel, - currencies = currencies, - onBackClick = onBackClick, - onClickQuarter = { - amountInputViewModel.setSats(uiState.quarterAmount, currencies) - }, - onClickMaxAmount = { - amountInputViewModel.setSats(uiState.maxAllowedToSend, currencies) - }, - onConfirmAmount = { viewModel.onConfirmAmount(amountUiState.sats) }, - ) - AnimatedVisibility( - visible = isOffline, - enter = fadeIn(), - exit = fadeOut(), - ) { - ConnectionIssuesView( - titleText = stringResource(R.string.lightning__transfer__nav_title), - modifier = Modifier.statusBarsPadding() - ) - } - } -} - -@Suppress("ViewModelForwarding") -@Composable -private fun Content( - isNodeRunning: Boolean = true, - uiState: TransferToSpendingUiState, - amountInputViewModel: AmountInputViewModel, - onBackClick: () -> Unit = {}, - onClickQuarter: () -> Unit = {}, - onClickMaxAmount: () -> Unit = {}, - onConfirmAmount: () -> Unit = {}, - currencies: CurrencyState = LocalCurrencies.current, -) { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.lightning__transfer__nav_title), - onBackClick = onBackClick, - actions = { DrawerNavIcon() }, - ) - - if (isNodeRunning) { - NodeRunning( - uiState = uiState, - amountInputViewModel = amountInputViewModel, - currencies = currencies, - onClickQuarter = onClickQuarter, - onClickMaxAmount = onClickMaxAmount, - onConfirmAmount = onConfirmAmount, - ) - } else { - SyncNodeView( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) - } - } -} - -@Suppress("ViewModelForwarding") -@Composable -private fun NodeRunning( - uiState: TransferToSpendingUiState, - amountInputViewModel: AmountInputViewModel, - currencies: CurrencyState, - onClickQuarter: () -> Unit = {}, - onClickMaxAmount: () -> Unit = {}, - onConfirmAmount: () -> Unit = {}, -) { - LaunchedEffect(uiState.maxAllowedToSend) { - amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend) - } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - .testTag("HardwareTransferAmount") - ) { - val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - - VerticalSpacer(32.dp) - - Display( - text = stringResource(R.string.lightning__spending_amount__title) - .withAccent(accentColor = Colors.Purple), - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(32.dp) - - NumberPadTextField( - viewModel = amountInputViewModel, - currencies = currencies, - showSecondaryField = false, - modifier = Modifier - .fillMaxWidth() - .testTag("HardwareTransferAmountNumberField") - ) - - FillHeight() - - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.testTag("HardwareTransferAmountNumberPad") - ) { - Column { - Text13Up( - text = stringResource(R.string.wallet__send_available), - color = Colors.White64, - modifier = Modifier.testTag("HardwareTransferAmountAvailable") - ) - VerticalSpacer(8.dp) - MoneySSB( - sats = uiState.balanceAfterFee, - showSymbol = true, - modifier = Modifier.testTag("HardwareTransferAmountUnit") - ) - } - FillWidth() - UnitButton( - color = Colors.Purple, - onClick = { amountInputViewModel.switchUnit(currencies) }, - modifier = Modifier.testTag("HardwareTransferAmountUnitButton") - ) - NumberPadActionButton( - text = stringResource(R.string.lightning__spending_amount__quarter), - color = Colors.Purple, - onClick = onClickQuarter, - modifier = Modifier.testTag("HardwareTransferAmountQuarter") - ) - NumberPadActionButton( - text = stringResource(R.string.common__max), - color = Colors.Purple, - onClick = onClickMaxAmount, - modifier = Modifier.testTag("HardwareTransferAmountMax") - ) - } - - HorizontalDivider(modifier = Modifier.padding(top = 12.dp)) - - NumberPad( - viewModel = amountInputViewModel, - currencies = currencies, - enabled = !uiState.isLoading, - ) - - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onConfirmAmount, - enabled = !uiState.isLoading && amountUiState.sats <= uiState.maxAllowedToSend, - isLoading = uiState.isLoading, - modifier = Modifier.testTag("HardwareTransferAmountContinue") - ) - - VerticalSpacer(16.dp) - } -} - -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, device = "id:pixel_9_pro_xl", name = "Large") -@Preview(showSystemUi = true, device = NEXUS_5, name = "Small") -@Composable -private fun Preview() { - AppThemeSurface { - Content( - uiState = TransferToSpendingUiState(maxAllowedToSend = 158_234, balanceAfterFee = 158_234), - amountInputViewModel = previewAmountInputViewModel(), - currencies = CurrencyState(), - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt deleted file mode 100644 index 42cc92f0f6..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt +++ /dev/null @@ -1,294 +0,0 @@ -package to.bitkit.ui.screens.transfer.hardware - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -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.models.HwWalletVendor -import to.bitkit.models.safe -import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FeeInfo -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.HardwareTransferIllustration -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.SIGN_VISUAL_TOP_RATIO -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.illustrationRes -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.screens.transfer.previewSpendingState -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.TransferToSpendingUiState -import to.bitkit.viewmodels.TransferViewModel - -@Composable -fun SpendingHwSignScreen( - walletId: String, - viewModel: TransferViewModel, - onBackClick: () -> Unit, - onCloseClick: () -> Unit, - onLearnMoreClick: () -> Unit, - onAdvancedClick: () -> Unit, -) { - val state by viewModel.spendingUiState.collectAsStateWithLifecycle() - val hardwareWallets by viewModel.hardwareWallets.collectAsStateWithLifecycle() - val vendor = remember(hardwareWallets, walletId) { - hardwareWallets.firstOrNull { it.id == walletId }?.vendor ?: HwWalletVendor.TREZOR - } - - if (state.feeSat == 0uL) { - onCloseClick() - return - } - - BackHandler(enabled = !state.canLeave) {} - - LaunchedEffect(walletId, state.feeSat) { - viewModel.warmUpHardwareConnection(walletId) - viewModel.updateHwFundingFeeEstimate(walletId) - } - - DisposableEffect(viewModel) { - onDispose(viewModel::cancelHardwareTransfer) - } - - Content( - state = state, - miningFeeSats = state.hwMiningFeeSats, - isAdvanced = state.isAdvanced, - isSigning = state.isBusy, - canLeave = state.canLeave, - hasPendingBroadcast = state.hasPendingHwBroadcast, - vendor = vendor, - onBackClick = { if (state.canLeave) onBackClick() }, - onLearnMoreClick = onLearnMoreClick, - onAdvancedClick = onAdvancedClick, - onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, - onOpenConnect = { viewModel.onTransferToSpendingHwConfirm(walletId) }, - ) - - if (state.isHwPassphraseRequired) { - HwPassphrasePromptSheet( - isVerifying = state.isVerifyingHwPassphrase, - onSubmit = { viewModel.onHwPassphraseSubmit(walletId, it) }, - onDismiss = viewModel::onHwPassphraseDismiss, - ) - } -} - -@Composable -private fun Content( - state: TransferToSpendingUiState, - miningFeeSats: ULong = 0uL, - isAdvanced: Boolean = false, - isSigning: Boolean = false, - canLeave: Boolean = true, - hasPendingBroadcast: Boolean = false, - vendor: HwWalletVendor = HwWalletVendor.TREZOR, - onBackClick: () -> Unit = {}, - onLearnMoreClick: () -> Unit = {}, - onAdvancedClick: () -> Unit = {}, - onUseDefaultLspBalanceClick: () -> Unit = {}, - onOpenConnect: () -> Unit = {}, -) { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.lightning__transfer__nav_title), - onBackClick = onBackClick, - actions = { if (canLeave) DrawerNavIcon() }, - ) - Box(modifier = Modifier.fillMaxSize()) { - HardwareTransferIllustration( - drawableRes = vendor.illustrationRes(), - topRatio = SIGN_VISUAL_TOP_RATIO, - ) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - .testTag("HardwareTransferSign") - ) { - VerticalSpacer(32.dp) - Display( - text = stringResource( - if (hasPendingBroadcast) { - R.string.lightning__transfer_hw__signed_title - } else { - R.string.lightning__transfer_hw__sign_title - } - ) - .withAccent(accentColor = Colors.Purple), - modifier = Modifier.fillMaxWidth() - ) - VerticalSpacer(16.dp) - - SpendingHwFeeGrid( - state = state, - miningFeeSats = miningFeeSats, - ) - - VerticalSpacer(24.dp) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - PrimaryButton( - text = stringResource(R.string.common__learn_more), - size = ButtonSize.Small, - fullWidth = false, - enabled = !isSigning && !hasPendingBroadcast, - onClick = onLearnMoreClick, - modifier = Modifier.testTag("HardwareTransferSignLearnMore") - ) - PrimaryButton( - text = stringResource( - if (isAdvanced) R.string.lightning__spending_confirm__default else R.string.common__advanced - ), - size = ButtonSize.Small, - fullWidth = false, - enabled = !isSigning && !hasPendingBroadcast, - onClick = { if (isAdvanced) onUseDefaultLspBalanceClick() else onAdvancedClick() }, - modifier = Modifier.testTag( - if (isAdvanced) "HardwareTransferSignDefault" else "HardwareTransferSignAdvanced" - ) - ) - } - - FillHeight() - - PrimaryButton( - text = stringResource( - if (hasPendingBroadcast) { - R.string.common__retry - } else { - R.string.lightning__transfer_hw__open_connect - } - ), - onClick = onOpenConnect, - enabled = !isSigning, - isLoading = isSigning, - modifier = Modifier.testTag("HardwareTransferOpenTrezorConnect") - ) - VerticalSpacer(16.dp) - } - } - } -} - -@Composable -internal fun SpendingHwFeeGrid( - state: TransferToSpendingUiState, - modifier: Modifier = Modifier, - miningFeeSats: ULong = 0uL, -) { - val lspFee = state.feeSat.safe() - state.clientBalanceSat.safe() - val total = state.feeSat.safe() + miningFeeSats.safe() - - Column(modifier = modifier) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.height(IntrinsicSize.Min) - ) { - FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__network_fee), - amount = miningFeeSats.toLong(), - ) - FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__lsp_fee), - amount = lspFee.toLong(), - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.height(IntrinsicSize.Min) - ) { - FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__amount), - amount = state.clientBalanceSat.toLong(), - ) - FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__total), - amount = total.toLong(), - ) - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun PreviewWithMiningFee() { - AppThemeSurface { - Content( - state = previewSpendingState( - clientBalanceSat = 7_042uL, - feeSat = 7_402uL, - ), - miningFeeSats = 141uL, - ) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview() { - AppThemeSurface { - Content( - state = previewSpendingState(), - ) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun PreviewAdvanced() { - AppThemeSurface { - Content( - state = previewSpendingState(), - isAdvanced = true, - ) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun PreviewSigning() { - AppThemeSurface { - Content( - state = previewSpendingState(), - isSigning = true, - canLeave = false, - ) - } -} - -@Preview(showSystemUi = true) -@Composable -private fun PreviewPendingBroadcast() { - AppThemeSurface { - Content( - state = previewSpendingState(), - hasPendingBroadcast = true, - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt deleted file mode 100644 index 46996f213d..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt +++ /dev/null @@ -1,109 +0,0 @@ -package to.bitkit.ui.screens.transfer.hardware - -import androidx.compose.foundation.layout.Box -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.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.delay -import to.bitkit.R -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.HardwareTransferIllustration -import to.bitkit.ui.components.SIGNED_VISUAL_TOP_RATIO -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.screens.transfer.previewSpendingState -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.TransferToSpendingUiState -import to.bitkit.viewmodels.TransferViewModel - -private const val SIGNED_AUTO_NAV_DELAY_MS = 1_000L - -@Composable -fun SpendingHwSignedScreen( - viewModel: TransferViewModel, - onContinue: () -> Unit, - onCloseClick: () -> Unit, -) { - val state by viewModel.spendingUiState.collectAsStateWithLifecycle() - - if (state.feeSat == 0uL) { - onCloseClick() - return - } - - LaunchedEffect(Unit) { - delay(SIGNED_AUTO_NAV_DELAY_MS) - onContinue() - } - - Content( - state = state, - miningFeeSats = state.hwMiningFeeSats, - onBackClick = onCloseClick, - ) -} - -@Composable -private fun Content( - state: TransferToSpendingUiState, - miningFeeSats: ULong = 0uL, - onBackClick: () -> Unit = {}, -) { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.lightning__transfer__nav_title), - onBackClick = onBackClick, - actions = { DrawerNavIcon() }, - ) - Box(modifier = Modifier.fillMaxSize()) { - HardwareTransferIllustration( - drawableRes = R.drawable.check, - topRatio = SIGNED_VISUAL_TOP_RATIO, - ) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - .testTag("HardwareTransferSigned") - ) { - VerticalSpacer(32.dp) - Display( - text = stringResource(R.string.lightning__transfer_hw__signed_title) - .withAccent(accentColor = Colors.Purple), - modifier = Modifier.fillMaxWidth() - ) - VerticalSpacer(16.dp) - - SpendingHwFeeGrid( - state = state, - miningFeeSats = miningFeeSats, - ) - } - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview() { - AppThemeSurface { - Content( - state = previewSpendingState(), - ) - } -} 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 3f68440496..d97cc12d32 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -167,8 +167,6 @@ import to.bitkit.ui.navToActivityDetail import to.bitkit.ui.navigateTo import to.bitkit.ui.navigateToAllActivity import to.bitkit.ui.navigateToProfile -import to.bitkit.ui.navigateToTransferFunding -import to.bitkit.ui.navigateToTransferIntro import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.screens.wallets.activity.components.ActivityListSimple import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems @@ -240,7 +238,6 @@ fun HomeScreen( ) { val context = LocalContext.current val resources = LocalResources.current - val hasSeenTransferIntro by settingsViewModel.hasSeenTransferIntro.collectAsStateWithLifecycle() val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() val profileNavigationScope = rememberCoroutineScope() @@ -316,13 +313,7 @@ fun HomeScreen( appViewModel.showSheet(Sheet.Hardware()) } - Suggestion.LIGHTNING -> { - if (!hasSeenTransferIntro) { - rootNavController.navigateToTransferIntro() - } else { - rootNavController.navigateToTransferFunding() - } - } + Suggestion.LIGHTNING -> appViewModel.showSheet(Sheet.TransferUnavailable) Suggestion.BACK_UP -> { appViewModel.showSheet(Sheet.Backup(BackupRoute.Intro)) @@ -400,7 +391,6 @@ fun HomeScreen( onPageChanged = homeViewModel::onPageChanged, onDismissWidgetsOnboardingHint = homeViewModel::dismissWidgetsOnboardingHint, onNavigateToAppStatus = { rootNavController.navigate(Routes.AppStatus) }, - onNavigateToSettingUp = { rootNavController.navigate(Routes.SettingUp) }, onNavigateToAllActivity = { rootNavController.navigateToAllActivity(activityListViewModel::clearFilters) }, onNavigateToActivityItem = { rootNavController.navToActivityDetail(it) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, @@ -441,7 +431,6 @@ private fun Content( onPageChanged: (Int) -> Unit = {}, onDismissWidgetsOnboardingHint: () -> Unit = {}, onNavigateToAppStatus: () -> Unit = {}, - onNavigateToSettingUp: () -> Unit = {}, onNavigateToAllActivity: () -> Unit = {}, onNavigateToActivityItem: (Activity) -> Unit = {}, onNavigateToSavings: () -> Unit = {}, @@ -579,7 +568,6 @@ private fun Content( balances = balances, isSmallScreen = isSmallScreen, onRefresh = onRefresh, - onNavigateToSettingUp = onNavigateToSettingUp, onNavigateToAllActivity = onNavigateToAllActivity, onNavigateToActivityItem = onNavigateToActivityItem, onNavigateToSavings = onNavigateToSavings, @@ -631,7 +619,6 @@ private fun WalletPage( balances: BalanceState, isSmallScreen: Boolean, onRefresh: () -> Unit, - onNavigateToSettingUp: () -> Unit, onNavigateToAllActivity: () -> Unit, onNavigateToActivityItem: (Activity) -> Unit, onNavigateToSavings: () -> Unit, @@ -720,12 +707,6 @@ private fun WalletPage( gradientColor = banner.type.color, title = banner.title, icon = banner.type.icon, - onClick = { - when (banner.type) { - ActivityBannerType.SPENDING -> onNavigateToSettingUp() - ActivityBannerType.SAVINGS -> Unit - } - }, modifier = Modifier.fillMaxWidth() ) } 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 295d21da45..2ba848186d 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 @@ -29,7 +29,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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 @@ -41,18 +40,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R -import to.bitkit.ext.runSuspendCatching import to.bitkit.models.ReceiveAdditionalLiquidityAction import to.bitkit.models.ReceiveLiquiditySource import to.bitkit.models.ReceiveLiquiditySource.AUTO import to.bitkit.models.ReceiveLiquiditySource.SAVINGS import to.bitkit.models.ReceiveLiquiditySource.SPENDING import to.bitkit.repositories.CurrencyState -import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletState import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.appViewModel -import to.bitkit.ui.blocktankViewModel import to.bitkit.ui.components.AddTagButton import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview @@ -72,8 +67,6 @@ import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.utils.Logger -import to.bitkit.utils.ServiceError import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.previewAmountInputViewModel @@ -82,7 +75,6 @@ import to.bitkit.viewmodels.previewAmountInputViewModel fun EditInvoiceScreen( amountInputViewModel: AmountInputViewModel, walletUiState: WalletState, - lightningState: LightningState, sourceTab: ReceiveTab, updateInvoice: (ULong?) -> Unit, onClickAddTag: () -> Unit, @@ -91,19 +83,13 @@ fun EditInvoiceScreen( showPaymentRequestButton: Boolean, onClickPaymentRequest: (amountSats: ULong, note: String) -> Unit, onBack: () -> Unit, - navigateReceiveConfirm: (CjitEntryDetails) -> Unit, onchainOnly: Boolean = false, updateOnchainInvoice: (ULong?) -> Unit = {}, - navigateCjitAmount: () -> Unit, - navigateGeoBlock: () -> Unit, + navigateTransferUnavailable: () -> Unit, currencies: CurrencyState = LocalCurrencies.current, editInvoiceVM: EditInvoiceVM = hiltViewModel(), ) { - val app = appViewModel ?: return - val context = LocalContext.current - val blocktankVM = blocktankViewModel ?: return var keyboardVisible by remember { mutableStateOf(false) } - var isCreatingCjit by remember { mutableStateOf(false) } val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val currentReceiveSats by rememberUpdatedState(amountInputUiState.sats.toULong()) val isLoading by editInvoiceVM.isLoading.collectAsStateWithLifecycle() @@ -119,29 +105,7 @@ fun EditInvoiceScreen( updateInvoice(receiveSats) onBack() } - ReceiveAdditionalLiquidityAction.ChooseAmount -> { - updateInvoice(receiveSats) - navigateCjitAmount() - } - is ReceiveAdditionalLiquidityAction.CreateCjit -> { - isCreatingCjit = true - runSuspendCatching { - val entry = blocktankVM.createCjit(action.amountSats) - CjitEntryDetails.from(entry, action.amountSats).getOrThrow() - }.onSuccess { - navigateReceiveConfirm(it) - }.onFailure { - Logger.error("Failed to create CJIT invoice", it, context = "EditInvoiceScreen") - if (!app.toastReceiveCjitError(context, it) && - it !is ServiceError.ChannelSizeExceedsMaximum - ) { - app.toast(it) - } - navigateCjitAmount() - } - isCreatingCjit = false - } - ReceiveAdditionalLiquidityAction.GeoBlocked -> navigateGeoBlock() + ReceiveAdditionalLiquidityAction.TransferUnavailable -> navigateTransferUnavailable() } } } @@ -168,14 +132,13 @@ fun EditInvoiceScreen( editInvoiceVM.onClickContinue( source = sourceTab.toReceiveLiquiditySource(), amountSats = amountInputUiState.sats.toULong(), - isGeoBlocked = lightningState.isGeoBlocked, ) }, onContinueOnchain = { amountSats -> updateOnchainInvoice(amountSats) onBack() }, - isLoading = isLoading || isCreatingCjit, + isLoading = isLoading, onClickAddTag = onClickAddTag, onClickTag = onClickTag, showPaymentRequestButton = showPaymentRequestButton, 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 654a0dcf43..273465d5a0 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,14 +13,12 @@ import to.bitkit.models.ReceiveAdditionalLiquidityAction import to.bitkit.models.ReceiveAdditionalLiquidityParams import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.models.ReceiveLiquiditySource -import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.WalletRepo import javax.inject.Inject @HiltViewModel class EditInvoiceVM @Inject constructor( private val walletRepo: WalletRepo, - private val blocktankRepo: BlocktankRepo, ) : ViewModel() { private val _editInvoiceEffect = MutableSharedFlow(extraBufferCapacity = 1) @@ -38,20 +36,15 @@ class EditInvoiceVM @Inject constructor( fun onClickContinue( source: ReceiveLiquiditySource, amountSats: ULong, - isGeoBlocked: Boolean, ) { viewModelScope.launch { _isLoading.update { true } val inboundCapacitySats = walletRepo.inboundLiquiditySats() - val maxCjitAmountSats = maxCjitAmountSats(source, amountSats, inboundCapacitySats, isGeoBlocked) val action = ReceiveLiquidityDecision.additionalLiquidityAction( ReceiveAdditionalLiquidityParams( source = source, invoiceAmountSats = amountSats, inboundCapacitySats = inboundCapacitySats, - minCjitSats = blocktankRepo.blocktankState.value.minCjitSats?.toULong(), - maxCjitAmountSats = maxCjitAmountSats, - isGeoBlocked = isGeoBlocked, ) ) editInvoiceEffect(EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(action)) @@ -59,26 +52,6 @@ class EditInvoiceVM @Inject constructor( } } - private suspend fun maxCjitAmountSats( - source: ReceiveLiquiditySource, - amountSats: ULong, - inboundCapacitySats: ULong, - isGeoBlocked: Boolean, - ): ULong? { - if (!ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( - source = source, - invoiceAmountSats = amountSats, - inboundCapacitySats = inboundCapacitySats, - isGeoBlocked = isGeoBlocked, - ) - ) { - return null - } - - blocktankRepo.refreshMinCjitSats() - return blocktankRepo.maxCjitAmountSats().getOrNull() - } - sealed interface EditInvoiceScreenEffects { data class ApplyReceiveLiquidityAction( val action: ReceiveAdditionalLiquidityAction, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt deleted file mode 100644 index e2c6b6fed7..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ /dev/null @@ -1,290 +0,0 @@ -package to.bitkit.ui.screens.wallets.receive - -import androidx.compose.foundation.layout.BoxWithConstraints -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.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 -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 kotlinx.coroutines.launch -import to.bitkit.R -import to.bitkit.ext.runSuspendCatching -import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.Toast -import to.bitkit.models.formatToModernDisplay -import to.bitkit.repositories.CurrencyState -import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.appViewModel -import to.bitkit.ui.blocktankViewModel -import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.FillWidth -import to.bitkit.ui.components.MoneySSB -import to.bitkit.ui.components.NumberPad -import to.bitkit.ui.components.NumberPadTextField -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.UnitButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.SheetTopBar -import to.bitkit.ui.shared.modifiers.clickableAlpha -import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.walletViewModel -import to.bitkit.utils.Logger -import to.bitkit.utils.ServiceError -import to.bitkit.viewmodels.AmountInputEffect -import to.bitkit.viewmodels.AmountInputViewModel -import to.bitkit.viewmodels.previewAmountInputViewModel - -@Suppress("ViewModelForwarding") -@Composable -fun ReceiveAmountScreen( - onCjitCreated: (CjitEntryDetails) -> Unit, - onBack: () -> Unit, - currencies: CurrencyState = LocalCurrencies.current, - amountInputViewModel: AmountInputViewModel = hiltViewModel(), -) { - val app = appViewModel ?: return - val context = LocalContext.current - val resources = LocalResources.current - val wallet = walletViewModel ?: return - val blocktank = blocktankViewModel ?: return - val lightningState by wallet.lightningState.collectAsStateWithLifecycle() - val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - - var isCreatingInvoice by remember { mutableStateOf(false) } - var maxCjitAmountSats by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - fun showMaxExceededToast(max: ULong) { - app.toast( - type = Toast.ToastType.WARNING, - title = resources.getString(R.string.wallet__receive_cjit_error_max__title), - description = resources.getString(R.string.wallet__receive_cjit_error_max__description) - .replace("{amount}", max.formatToModernDisplay()), - visibilityTime = Toast.VISIBILITY_TIME_SHORT, - testTag = "ReceiveCjitAmountExceededToast", - ) - } - - LaunchedEffect(Unit) { - blocktank.refreshMinCjitSats() - maxCjitAmountSats = runSuspendCatching { blocktank.maxCjitAmountSats() }.getOrNull() - } - - LaunchedEffect(maxCjitAmountSats, amountInputUiState.sats) { - val max = maxCjitAmountSats - amountInputViewModel.setMaxAmount(maxCjitAmountSats?.toLong() ?: 0L) - if (max != null && amountInputUiState.sats.toULong() > max) { - amountInputViewModel.setSats(max.toLong(), currencies) - showMaxExceededToast(max) - } - } - - LaunchedEffect(Unit) { - amountInputViewModel.effect.collect { - when (it) { - AmountInputEffect.MaxExceeded -> { - val max = maxCjitAmountSats ?: return@collect - amountInputViewModel.setSats(max.toLong(), currencies) - showMaxExceededToast(max) - } - } - } - } - - val minCjitSats by blocktank.minCjitSats.collectAsStateWithLifecycle() - - ReceiveAmountContent( - amountInputViewModel = amountInputViewModel, - minCjitSats = minCjitSats, - currencies = currencies, - isCreatingInvoice = isCreatingInvoice, - canContinue = amountInputUiState.sats >= (minCjitSats?.toLong() ?: 0) && - (maxCjitAmountSats?.let { amountInputUiState.sats.toULong() <= it } ?: true), - onBack = onBack, - onClickMin = { amountInputViewModel.setSats(it, currencies) }, - onContinue = { - val sats = amountInputUiState.sats - scope.launch { - val max = maxCjitAmountSats - if (max != null && sats.toULong() > max) { - amountInputViewModel.setSats(max.toLong(), currencies) - showMaxExceededToast(max) - return@launch - } - isCreatingInvoice = true - runSuspendCatching { - require(lightningState.nodeLifecycleState == NodeLifecycleState.Running) { - "Should not be able to land on this screen if the node is not running." - } - - val entry = blocktank.createCjit(amountSats = sats.toULong()) - onCjitCreated(CjitEntryDetails.from(entry, sats.toULong()).getOrThrow()) - }.onFailure { e -> - Logger.error("Failed to create CJIT", e) - when { - e is ServiceError.ChannelSizeExceedsMaximum -> { - maxCjitAmountSats = runSuspendCatching { blocktank.maxCjitAmountSats() }.getOrNull() - maxCjitAmountSats?.let { showMaxExceededToast(it) } ?: app.toast(e) - } - !app.toastReceiveCjitError(context, e) -> { - app.toast(e) - } - } - } - isCreatingInvoice = false - } - } - ) -} - -@Suppress("ViewModelForwarding") -@Composable -private fun ReceiveAmountContent( - amountInputViewModel: AmountInputViewModel, - minCjitSats: Int?, - isCreatingInvoice: Boolean, - canContinue: Boolean, - modifier: Modifier = Modifier, - currencies: CurrencyState = LocalCurrencies.current, - onClickMin: (Long) -> Unit = {}, - onBack: () -> Unit = {}, - onContinue: () -> Unit = {}, -) { - Column( - modifier = modifier - .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() - .testTag("ReceiveAmount") - ) { - SheetTopBar( - titleText = stringResource(R.string.wallet__receive_bitcoin), - onBack = onBack, - ) - - BoxWithConstraints { - val maxHeight = this.maxHeight - - Column( - modifier = Modifier.padding(horizontal = 16.dp) - ) { - VerticalSpacer(16.dp) - NumberPadTextField( - viewModel = amountInputViewModel, - modifier = Modifier - .fillMaxWidth() - .testTag("ReceiveNumberPadTextField") - ) - - FillHeight(min = 12.dp) - - // Min amount row - Row( - verticalAlignment = Alignment.Bottom, - modifier = Modifier.fillMaxWidth() - ) { - minCjitSats?.let { minCjitSats -> - Column( - modifier = Modifier - .clickableAlpha { onClickMin(minCjitSats.toLong()) } - .testTag("ReceiveAmountMin") - ) { - Caption13Up( - text = stringResource(R.string.wallet__minimum), - color = Colors.White64, - ) - VerticalSpacer(8.dp) - MoneySSB(sats = minCjitSats.toLong(), showSymbol = true) - } - } ?: CircularProgressIndicator(modifier = Modifier.size(18.dp)) - - FillWidth() - UnitButton( - onClick = { amountInputViewModel.switchUnit(currencies) }, - modifier = Modifier.testTag("ReceiveNumberPadUnit") - ) - } - - VerticalSpacer(16.dp) - HorizontalDivider() - - NumberPad( - viewModel = amountInputViewModel, - currencies = currencies, - availableHeight = maxHeight, - modifier = Modifier - .testTag("ReceiveNumberPad") - ) - - PrimaryButton( - text = stringResource(R.string.common__continue), - enabled = !isCreatingInvoice && canContinue, - isLoading = isCreatingInvoice, - onClick = onContinue, - modifier = Modifier.testTag("ContinueAmount") - ) - - VerticalSpacer(16.dp) - } - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview() { - AppThemeSurface { - BottomSheetPreview { - ReceiveAmountContent( - amountInputViewModel = previewAmountInputViewModel(), - canContinue = true, - minCjitSats = 5000, - isCreatingInvoice = false, - modifier = Modifier.sheetHeight(), - ) - } - } -} - -@Preview(showSystemUi = true, device = NEXUS_5) -@Composable -private fun PreviewSmallScreen() { - AppThemeSurface { - BottomSheetPreview { - ReceiveAmountContent( - amountInputViewModel = previewAmountInputViewModel(sats = 200), - canContinue = true, - minCjitSats = 5000, - isCreatingInvoice = false, - modifier = Modifier.sheetHeight(), - ) - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrorPresenter.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrorPresenter.kt deleted file mode 100644 index 412f3fffce..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrorPresenter.kt +++ /dev/null @@ -1,33 +0,0 @@ -package to.bitkit.ui.screens.wallets.receive - -import android.content.Context -import to.bitkit.R -import to.bitkit.models.Toast -import to.bitkit.utils.ServiceError -import to.bitkit.viewmodels.AppViewModel - -internal fun AppViewModel.toastReceiveCjitError( - context: Context, - error: Throwable, -): Boolean { - val title: String - val description: String - when (error) { - is ServiceError.CjitQuoteInvalid -> { - title = context.getString(R.string.wallet__receive_cjit_error_invalid__title) - description = context.getString(R.string.wallet__receive_cjit_error_invalid__description) - } - is ServiceError.NodeCapacityUnavailable -> { - title = context.getString(R.string.wallet__receive_cjit_error_node_capacity__title) - description = context.getString(R.string.wallet__receive_cjit_error_node_capacity__description) - } - else -> return false - } - - toast( - type = Toast.ToastType.ERROR, - title = title, - description = description, - ) - return true -} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt deleted file mode 100644 index 0bb147def5..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt +++ /dev/null @@ -1,308 +0,0 @@ -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.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 -import androidx.compose.ui.tooling.preview.Devices.PIXEL_TABLET -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 com.synonym.bitkitcore.IcJitEntry -import kotlinx.serialization.Serializable -import to.bitkit.R -import to.bitkit.models.CjitQuoteValidator -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.PrimaryButton -import to.bitkit.ui.components.SecondaryButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.rememberMoneyText -import to.bitkit.ui.components.settings.SettingsSwitchRow -import to.bitkit.ui.currencyViewModel -import to.bitkit.ui.openNotificationSettings -import to.bitkit.ui.scaffold.SheetTopBar -import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.AppSwitchDefaults -import to.bitkit.ui.theme.AppTextStyles -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.rememberNotificationToggleClick -import to.bitkit.ui.utils.withAccent -import to.bitkit.viewmodels.SettingsViewModel - -@Composable -fun ReceiveConfirmScreen( - settingsViewModel: SettingsViewModel = hiltViewModel(), - entry: CjitEntryDetails, - isAdditional: Boolean = false, - onLearnMore: () -> Unit, - onContinue: (String) -> Unit, - onBack: () -> Unit, -) { - val context = LocalContext.current - - val currency = currencyViewModel ?: return - val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() - - val networkFeeFormatted = remember(entry.networkFeeSat) { - currency.convert(entry.networkFeeSat) - ?.let { converted -> converted.formattedWithSymbol() } - ?: entry.networkFeeSat.toString() - } - - val serviceFeeFormatted = remember(entry.serviceFeeSat) { - currency.convert(entry.serviceFeeSat) - ?.let { converted -> converted.formattedWithSymbol() } - ?: entry.serviceFeeSat.toString() - } - - val receiveAmountSats = entry.receiveAmountSats - entry.feeSat - val receiveAmountFormatted = rememberMoneyText( - sats = receiveAmountSats, - showSymbol = true, - ) ?: receiveAmountSats.toString() - - val onNotificationSwitchClick = rememberNotificationToggleClick( - isGranted = notificationsGranted, - onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, - onOpenSystemSettings = { context.openNotificationSettings() }, - ) - - Content( - receiveSats = entry.receiveAmountSats, - networkFeeFormatted = networkFeeFormatted, - serviceFeeFormatted = serviceFeeFormatted, - receiveAmountFormatted = receiveAmountFormatted, - onLearnMoreClick = onLearnMore, - isAdditional = isAdditional, - onSystemSettingsClick = onNotificationSwitchClick, - hasNotificationPermission = notificationsGranted, - onContinueClick = { onContinue(entry.invoice) }, - onBackClick = onBack, - ) -} - -@Composable -private fun Content( - receiveSats: Long, - isAdditional: Boolean, - networkFeeFormatted: String, - serviceFeeFormatted: String, - receiveAmountFormatted: String, - onSystemSettingsClick: () -> Unit, - hasNotificationPermission: Boolean, - onLearnMoreClick: () -> Unit, - onContinueClick: () -> Unit, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() - ) { - SheetTopBar(stringResource(R.string.wallet__receive_bitcoin), onBack = onBackClick) - VerticalSpacer(24.dp) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - BalanceHeaderView( - sats = receiveSats, - modifier = Modifier.fillMaxWidth() - ) - VerticalSpacer(24.dp) - val text = when (isAdditional) { - true -> stringResource(R.string.wallet__receive_connect_additional) - else -> stringResource(R.string.wallet__receive_connect_initial) - } - BodyM( - text = text - .replace("{networkFee}", networkFeeFormatted) - .replace("{serviceFee}", serviceFeeFormatted) - .withAccent( - defaultColor = Colors.White64, - accentStyle = SpanStyle(color = Colors.White, fontWeight = FontWeight.Bold) - ) - ) - VerticalSpacer(32.dp) - Column { - Caption13Up(text = stringResource(R.string.wallet__receive_will), color = Colors.White64) - VerticalSpacer(4.dp) - Text( - text = receiveAmountFormatted.withAccent( - defaultColor = Colors.White, - accentColor = Colors.White64, - ), - style = AppTextStyles.Title, - ) - } - - FillHeight() - - SettingsSwitchRow( - title = stringResource(R.string.settings__bg__setup), - isChecked = hasNotificationPermission, - colors = AppSwitchDefaults.colorsPurple, - onClick = onSystemSettingsClick, - switchTestTag = "ReceiveConfirmNotificationSwitch", - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(22.dp) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - SecondaryButton( - text = stringResource(R.string.common__learn_more), - onClick = onLearnMoreClick, - modifier = Modifier.weight(1f) - ) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onContinueClick, - modifier = Modifier.weight(1f) - ) - } - VerticalSpacer(16.dp) - } - } -} - -// TODO replace with direct use of the now serializable IcJitEntry -@Serializable -data class CjitEntryDetails( - val networkFeeSat: Long, - val serviceFeeSat: Long, - val channelSizeSat: Long, - val feeSat: Long, - val receiveAmountSats: Long, - val invoice: String, -) { - companion object { - fun from(entry: IcJitEntry, receiveAmountSats: ULong): Result { - return CjitQuoteValidator.validate( - invoiceSat = receiveAmountSats, - feeSat = entry.feeSat, - channelSizeSat = entry.channelSizeSat, - ).map { - CjitEntryDetails( - networkFeeSat = entry.networkFeeSat.toLong(), - serviceFeeSat = entry.serviceFeeSat.toLong(), - channelSizeSat = entry.channelSizeSat.toLong(), - feeSat = entry.feeSat.toLong(), - receiveAmountSats = receiveAmountSats.toLong(), - invoice = entry.invoice.request, - ) - } - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun Preview() { - AppThemeSurface { - BottomSheetPreview { - Content( - receiveSats = 12500L, - isAdditional = false, - networkFeeFormatted = "$0.50", - serviceFeeFormatted = "$1.00", - receiveAmountFormatted = "$100.00", - onLearnMoreClick = {}, - onContinueClick = {}, - onBackClick = {}, - hasNotificationPermission = true, - onSystemSettingsClick = {}, - modifier = Modifier.sheetHeight(), - ) - } - } -} - -@Preview(showSystemUi = true) -@Composable -private fun PreviewAdditional() { - AppThemeSurface { - BottomSheetPreview { - Content( - receiveSats = 12500L, - isAdditional = true, - networkFeeFormatted = "$0.50", - serviceFeeFormatted = "$1.00", - receiveAmountFormatted = "$100.00", - onLearnMoreClick = {}, - onContinueClick = {}, - onBackClick = {}, - hasNotificationPermission = true, - onSystemSettingsClick = {}, - modifier = Modifier.sheetHeight(), - ) - } - } -} - -@Preview(showBackground = true, device = NEXUS_5) -@Composable -private fun PreviewSmall() { - AppThemeSurface { - BottomSheetPreview { - Content( - receiveSats = 12500L, - isAdditional = true, - networkFeeFormatted = "$0.50", - serviceFeeFormatted = "$1.00", - receiveAmountFormatted = "$100.00", - onLearnMoreClick = {}, - onContinueClick = {}, - onBackClick = {}, - hasNotificationPermission = false, - onSystemSettingsClick = {}, - modifier = Modifier.sheetHeight(), - ) - } - } -} - -@Preview(showSystemUi = true, device = PIXEL_TABLET) -@Composable -private fun PreviewTablet() { - AppThemeSurface { - BottomSheetPreview { - Content( - receiveSats = 1250L, - isAdditional = true, - networkFeeFormatted = "$0.50", - serviceFeeFormatted = "$1.00", - receiveAmountFormatted = "$100.00", - onLearnMoreClick = {}, - onContinueClick = {}, - onBackClick = {}, - hasNotificationPermission = true, - onSystemSettingsClick = {}, - modifier = Modifier.sheetHeight(), - ) - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt index 09029cc32e..cd157e6204 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt @@ -5,12 +5,11 @@ import to.bitkit.utils.Bip21Utils internal fun getReceiveInvoiceDisplayMode( tab: ReceiveTab, - cjitInvoice: String?, hasInvoiceError: Boolean, isUpdating: Boolean, showDetails: Boolean, ): ReceiveInvoiceDisplayMode { - if (tab.usesLightningInvoice() && cjitInvoice.isNullOrEmpty()) { + if (tab.usesLightningInvoice()) { if (isUpdating) return ReceiveInvoiceDisplayMode.LOADING if (hasInvoiceError) return ReceiveInvoiceDisplayMode.ERROR } @@ -23,7 +22,6 @@ internal fun getReceiveInvoiceDisplayMode( * @param tab The selected receive tab * @param bip21 Full BIP21 invoice (onchain + lightning) * @param bolt11 Lightning invoice - * @param cjitInvoice CJIT invoice from Blocktank (if active) * @param onchainAddress Pure Bitcoin address (fallback) * @return The invoice string to display/encode in QR */ @@ -31,7 +29,6 @@ fun getInvoiceForTab( tab: ReceiveTab, bip21: String, bolt11: String, - cjitInvoice: String?, isNodeRunning: Boolean, canCreateLightningInvoice: Boolean = true, onchainAddress: String, @@ -40,7 +37,7 @@ fun getInvoiceForTab( hardwareMessage: String = "", isReceiveInvoiceUpdating: Boolean = false, ): String { - if (isReceiveInvoiceUpdating && cjitInvoice.isNullOrEmpty() && tab.usesLightningInvoice()) { + if (isReceiveInvoiceUpdating && tab.usesLightningInvoice()) { return "" } @@ -59,9 +56,8 @@ fun getInvoiceForTab( } ReceiveTab.SPENDING -> { - // Lightning only: prefer CJIT > bolt11, empty when node is not running - cjitInvoice?.takeIf { it.isNotEmpty() && isNodeRunning } - ?: bolt11.takeIf { isNodeRunning && canCreateLightningInvoice }.orEmpty() + // Lightning only: empty when node is not running + bolt11.takeIf { isNodeRunning && canCreateLightningInvoice }.orEmpty() } ReceiveTab.USDT -> "" diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt deleted file mode 100644 index 07861ca9db..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveLiquidityScreen.kt +++ /dev/null @@ -1,221 +0,0 @@ -package to.bitkit.ui.screens.wallets.receive - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -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 to.bitkit.R -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodyMB -import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.ChannelStatusUi -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.LightningChannel -import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.components.settings.SettingsSwitchRow -import to.bitkit.ui.scaffold.SheetTopBar -import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.AppSwitchDefaults -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors -import to.bitkit.ui.utils.RequestNotificationPermissions -import to.bitkit.viewmodels.SettingsViewModel -import kotlin.math.round - -@Composable -fun ReceiveLiquidityScreen( - entry: CjitEntryDetails, - onContinue: () -> Unit, - onBack: () -> Unit, - onSwitchClick: () -> Unit, - hasNotificationPermission: Boolean, - modifier: Modifier = Modifier, - isAdditional: Boolean = false, - settingsViewModel: SettingsViewModel = hiltViewModel(), -) { - RequestNotificationPermissions( - onPermissionChange = { granted -> - settingsViewModel.setNotificationPreference(granted) - }, - showPermissionDialog = false - ) - - Content( - entry = entry, - onContinue = onContinue, - onBack = onBack, - onSwitchClick = onSwitchClick, - hasNotificationPermission = hasNotificationPermission, - modifier = modifier, - isAdditional = isAdditional - ) -} - -@Composable -private fun Content( - entry: CjitEntryDetails, - onContinue: () -> Unit, - onBack: () -> Unit, - onSwitchClick: () -> Unit, - hasNotificationPermission: Boolean, - modifier: Modifier = Modifier, - isAdditional: Boolean = false, -) { - val channelSize = entry.channelSizeSat - val localBalance = entry.receiveAmountSats - entry.feeSat - - val remoteBalance = remember(entry) { - val remoteReserve = channelSize / 100.0 - round(channelSize - localBalance - remoteReserve).toLong() - } - Column( - modifier = modifier - .fillMaxWidth() - .gradientBackground() - .navigationBarsPadding() - ) { - SheetTopBar( - stringResource( - if (isAdditional) { - R.string.wallet__receive_liquidity__nav_title_additional - } else { - R.string.wallet__receive_liquidity__nav_title - } - ), - onBack = onBack - ) - Spacer(Modifier.height(24.dp)) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()) - .weight(1f) - ) { - BodyM( - text = stringResource( - if (isAdditional) { - R.string.wallet__receive_liquidity__text_additional - } else { - R.string.wallet__receive_liquidity__text - } - ), - color = Colors.White64 - ) - - VerticalSpacer(32.dp) - - BodyMB( - text = stringResource( - if (isAdditional) { - R.string.wallet__receive_liquidity__label_additional - } else { - R.string.wallet__receive_liquidity__label - } - ) - ) - Spacer(modifier = Modifier.height(16.dp)) - - LightningChannel( - capacity = channelSize, - localBalance = localBalance, - remoteBalance = remoteBalance, - status = ChannelStatusUi.OPEN, - showLabels = true, - ) - - Spacer(modifier = Modifier.height(32.dp)) - - FillHeight() - - BodyM( - text = stringResource(R.string.wallet__receive_liquidity__bg_setup_desc), - color = Colors.White64 - ) - - VerticalSpacer(15.dp) - - SettingsSwitchRow( - title = stringResource(R.string.wallet__receive_liquidity__bg_setup_switch), - isChecked = hasNotificationPermission, - colors = AppSwitchDefaults.colorsPurple, - onClick = onSwitchClick, - switchTestTag = "ReceiveLiquidityNotificationSwitch", - modifier = Modifier.fillMaxWidth() - ) - - VerticalSpacer(22.dp) - - PrimaryButton( - text = stringResource(R.string.common__understood), - onClick = onContinue, - modifier = Modifier.testTag("LiquidityContinue") - ) - Spacer(modifier = Modifier.height(32.dp)) - } - } -} - -@Preview(showSystemUi = true, name = "Initial flow") -@Composable -private fun Preview() { - AppThemeSurface { - BottomSheetPreview { - Content( - entry = CjitEntryDetails( - channelSizeSat = 200_000L, - receiveAmountSats = 50_000L, - feeSat = 10_000L, - networkFeeSat = 5_000L, - serviceFeeSat = 150_000L, - invoice = "", - ), - isAdditional = false, - onContinue = {}, - onBack = {}, - onSwitchClick = {}, - hasNotificationPermission = true, - modifier = Modifier.sheetHeight(), - ) - } - } -} - -@Preview(showSystemUi = true, name = "Additional flow") -@Composable -private fun Preview2() { - AppThemeSurface { - BottomSheetPreview { - Content( - entry = CjitEntryDetails( - channelSizeSat = 200_000L, - receiveAmountSats = 50_000L, - feeSat = 10_000L, - networkFeeSat = 5_000L, - serviceFeeSat = 150_000L, - invoice = "", - ), - isAdditional = true, - onContinue = {}, - onBack = {}, - onSwitchClick = {}, - hasNotificationPermission = false, - modifier = Modifier.sheetHeight(), - ) - } - } -} 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 f99e7a16c5..5e80a36293 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 @@ -102,7 +102,6 @@ import to.bitkit.ui.utils.withAccent @Composable @Suppress("CyclomaticComplexMethod") fun ReceiveQrScreen( - cjitInvoice: String?, walletState: WalletState, lightningState: LightningState, onClickEditInvoice: (ReceiveTab) -> Unit, @@ -147,17 +146,17 @@ fun ReceiveQrScreen( val usdtEnabled by appViewModel?.isUsdtEnabled?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(false) } - val visibleTabs = remember(canCreateLightningInvoice, cjitInvoice, hardwareWalletId, usdtEnabled) { + val visibleTabs = remember(canCreateLightningInvoice, hardwareWalletId, usdtEnabled) { buildList { if (hardwareWalletId != null) { add(ReceiveTab.HARDWARE) } add(ReceiveTab.SAVINGS) - if (canCreateLightningInvoice && cjitInvoice.isNullOrEmpty()) { + if (canCreateLightningInvoice) { add(ReceiveTab.AUTO) } add(ReceiveTab.SPENDING) - if (usdtEnabled && cjitInvoice == null) add(ReceiveTab.USDT) + if (usdtEnabled) add(ReceiveTab.USDT) }.toImmutableList() } val defaultTab = remember(visibleTabs, initialTab) { @@ -169,7 +168,6 @@ fun ReceiveQrScreen( walletState.bip21, walletState.bolt11, walletState.onchainAddress, - cjitInvoice, lightningState.nodeLifecycleState, hardwareReceiveState.address, walletState.bip21AmountSats, @@ -181,7 +179,6 @@ fun ReceiveQrScreen( tab = tab, bip21 = walletState.bip21, bolt11 = walletState.bolt11, - cjitInvoice = cjitInvoice, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), canCreateLightningInvoice = canCreateLightningInvoice, onchainAddress = walletState.onchainAddress, @@ -234,11 +231,11 @@ fun ReceiveQrScreen( } } - LaunchedEffect(canCreateLightningInvoice, cjitInvoice, initialTab) { + LaunchedEffect(canCreateLightningInvoice, initialTab) { if (initialTab == ReceiveTab.HARDWARE || initialTab == ReceiveTab.USDT || selectedTab == ReceiveTab.USDT) { return@LaunchedEffect } - if (!canCreateLightningInvoice && cjitInvoice.isNullOrEmpty()) { + if (!canCreateLightningInvoice) { selectedTab = ReceiveTab.SAVINGS lazyListState.scrollToItem(visibleTabs.pageIndexOf(ReceiveTab.SAVINGS)) } @@ -275,12 +272,11 @@ fun ReceiveQrScreen( } // Jump to AUTO tab without animation when it becomes available before the user picks a tab - LaunchedEffect(canCreateLightningInvoice, cjitInvoice) { + LaunchedEffect(canCreateLightningInvoice) { val shouldAutoSwitch = shouldAutoSwitchToAuto( selectedTab = selectedTab, hasUserSelectedTab = hasUserSelectedTab, canCreateLightningInvoice = canCreateLightningInvoice, - cjitInvoice = cjitInvoice, initialTab = initialTab, ) if (!shouldAutoSwitch) return@LaunchedEffect @@ -291,16 +287,6 @@ fun ReceiveQrScreen( selectedTab = ReceiveTab.AUTO } - // Auto-switch to Spending tab when CJIT is not null - LaunchedEffect(cjitInvoice) { - if (cjitInvoice != null) { - if (ReceiveTab.SPENDING in visibleTabs) { - lazyListState.animateScrollToItem(visibleTabs.pageIndexOf(ReceiveTab.SPENDING)) - selectedTab = ReceiveTab.SPENDING - } - } - } - LaunchedEffect(selectedTab, hardwareWalletId) { showDetails = false if (selectedTab == ReceiveTab.HARDWARE && hardwareWalletId != null) { @@ -308,10 +294,8 @@ fun ReceiveQrScreen( } } - val showingCjitOnboarding = remember(lightningState, cjitInvoice, canCreateLightningInvoice) { - !canCreateLightningInvoice && - lightningState.nodeLifecycleState.isRunning() && - cjitInvoice.isNullOrEmpty() + val showingCjitOnboarding = remember(lightningState, canCreateLightningInvoice) { + !canCreateLightningInvoice && lightningState.nodeLifecycleState.isRunning() } fun selectReceiveTab(tab: ReceiveTab) { @@ -426,7 +410,6 @@ fun ReceiveQrScreen( ) { _, tab -> val displayMode = getReceiveInvoiceDisplayMode( tab = tab, - cjitInvoice = cjitInvoice, hasInvoiceError = walletState.hasReceiveInvoiceError, isUpdating = walletState.isReceiveInvoiceUpdating, showDetails = showDetails, @@ -465,7 +448,6 @@ fun ReceiveQrScreen( ReceiveDetailsView( tab = tab, walletState = walletState, - cjitInvoice = cjitInvoice, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), onClickEditInvoice = { onClickEditInvoice(tab) }, onClickHardwareEditInvoice = onClickHardwareEditInvoice, @@ -604,12 +586,11 @@ internal fun shouldAutoSwitchToAuto( selectedTab: ReceiveTab, hasUserSelectedTab: Boolean, canCreateLightningInvoice: Boolean, - cjitInvoice: String?, initialTab: ReceiveTab?, ): Boolean { if (initialTab != null || hasUserSelectedTab) return false if (selectedTab == ReceiveTab.AUTO) return false - return canCreateLightningInvoice && cjitInvoice.isNullOrEmpty() + return canCreateLightningInvoice } @OptIn(ExperimentalMaterial3Api::class) @@ -766,7 +747,6 @@ fun CjitOnBoardingView(modifier: Modifier = Modifier) { private fun ReceiveDetailsView( tab: ReceiveTab, walletState: WalletState, - cjitInvoice: String?, isNodeRunning: Boolean, onClickEditInvoice: () -> Unit, modifier: Modifier = Modifier, @@ -815,10 +795,10 @@ private fun ReceiveDetailsView( testTag = "ReceiveOnchainAddress", ) } - if (cjitInvoice != null || walletState.bolt11.isNotEmpty()) { + if (walletState.bolt11.isNotEmpty()) { CopyAddressCard( title = stringResource(R.string.wallet__receive_lightning_invoice), - address = cjitInvoice ?: walletState.bolt11, + address = walletState.bolt11, type = CopyAddressType.LIGHTNING, onClickEditInvoice = onClickEditInvoice, testTag = "ReceiveLightningAddress", @@ -827,7 +807,7 @@ private fun ReceiveDetailsView( } ReceiveTab.SPENDING -> { - val lightningInvoice = cjitInvoice ?: walletState.bolt11.takeIf { isNodeRunning } + val lightningInvoice = walletState.bolt11.takeIf { isNodeRunning } if (!lightningInvoice.isNullOrEmpty()) { CopyAddressCard( title = stringResource(R.string.wallet__receive_lightning_invoice), @@ -1041,7 +1021,6 @@ private fun PreviewSavingsMode() { AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( - cjitInvoice = null, walletState = WalletState( onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", ), @@ -1108,7 +1087,6 @@ private fun PreviewAutoMode() { AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( - cjitInvoice = null, walletState = WalletState( onchainAddress = "bcrt1qfserxgtuesul4m9zva56wzk849yf9l8rk4qy0l", bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + @@ -1179,7 +1157,6 @@ private fun PreviewSpendingMode() { AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( - cjitInvoice = null, walletState = WalletState( bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq" @@ -1203,7 +1180,6 @@ private fun PreviewNodeNotReady() { AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( - cjitInvoice = null, walletState = WalletState(), lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Starting, @@ -1222,7 +1198,6 @@ private fun PreviewSmall() { AppThemeSurface { BottomSheetPreview { ReceiveQrScreen( - cjitInvoice = null, walletState = WalletState(), lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Running, @@ -1253,7 +1228,6 @@ private fun PreviewDetailsMode() { bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq" ), - cjitInvoice = null, isNodeRunning = true, onClickEditInvoice = {}, modifier = Modifier.weight(1f) @@ -1280,7 +1254,6 @@ private fun PreviewDetailsModeSpendingLoading() { bolt11 = "lnbcrt500u1pn7umn7pp5x0s9lt9fwrff6rp70pz3guwnjgw97sjuv79vhx9n2ps8q6tcdehhxapqd9h8vmmfv" + "djjqen0wgsyqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxq" ), - cjitInvoice = null, isNodeRunning = false, onClickEditInvoice = {}, modifier = Modifier.weight(1f) 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 b6b56c32fc..f0de3aaead 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 @@ -17,7 +17,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -40,7 +39,6 @@ import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.modelNameRes import to.bitkit.ui.navigateTo -import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.screens.paymentrequests.PaymentRequestAmountScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestDetailsScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestRecipientScreen @@ -50,11 +48,9 @@ import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions -import to.bitkit.ui.utils.rememberNotificationToggleClick import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.AppViewModel -import to.bitkit.viewmodels.SettingsViewModel import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime @@ -73,7 +69,6 @@ fun ReceiveSheet( hardwareWalletId: String? = null, editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), paymentRequestAmountViewModel: AmountInputViewModel = hiltViewModel(key = "PaymentRequestAmount"), - settingsViewModel: SettingsViewModel = hiltViewModel(), hwReceiveViewModel: HwReceiveViewModel = hiltViewModel(), ) { val wallet = requireNotNull(walletViewModel) @@ -82,15 +77,12 @@ fun ReceiveSheet( val currencies = LocalCurrencies.current LaunchedEffect(Unit) { editInvoiceAmountViewModel.clearInput() } - val cjitSessionState = remember { ReceiveCjitSessionState() } val invoiceEditState = remember { ReceiveInvoiceEditState() } LaunchedEffect(startRoute) { - cjitSessionState.clear() navController.navigateToReceiveStart(startRoute) } var editInvoiceSourceTab by remember { mutableStateOf(ReceiveTab.SAVINGS) } - var isAdditionalLiquidityAmountEntry by remember { mutableStateOf(false) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val paymentRequestContacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() @@ -154,17 +146,9 @@ fun ReceiveSheet( composableWithDefaultTransitions { ReceiveQrScreen( onUsdtSelected = onUsdtSelected, - cjitInvoice = cjitSessionState.cjitInvoice, walletState = walletState, lightningState = lightningState, - onClickReceiveCjit = { - if (lightningState.isGeoBlocked) { - navController.navigateTo(ReceiveRoute.GeoBlock) - } else { - isAdditionalLiquidityAmountEntry = lightningState.channels.isNotEmpty() - navController.navigateTo(ReceiveRoute.Amount) - } - }, + onClickReceiveCjit = { navController.navigateTo(ReceiveRoute.TransferUnavailable) }, onClickEditInvoice = { editInvoiceSourceTab = it invoiceEditState.beginSoftwareEdit(it) @@ -283,116 +267,21 @@ fun ReceiveSheet( ) } } - composableWithDefaultTransitions { - ReceiveAmountScreen( - onCjitCreated = { entry -> - cjitSessionState.onCjitCreated(entry) - navController.navigateTo( - if (isAdditionalLiquidityAmountEntry) { - ReceiveRoute.ConfirmIncreaseInbound - } else { - ReceiveRoute.Confirm - } - ) - }, + composableWithDefaultTransitions { + TransferUnavailableScreen( onBack = { navController.popBackStack() }, + onContinue = navigateToExternalConnection, ) } - composableWithDefaultTransitions { - LocationBlockScreen( - onBackPressed = { navController.popBackStack() }, - navigateAdvancedSetup = navigateToExternalConnection, - ) - } - composableWithDefaultTransitions { - cjitSessionState.entryDetails?.let { entryDetails -> - ReceiveConfirmScreen( - entry = entryDetails, - onLearnMore = { navController.navigateTo(ReceiveRoute.Liquidity) }, - onContinue = { invoice -> - wallet.updateOnchainBip21Amount(entryDetails.receiveAmountSats.toULong()) - cjitSessionState.onCjitConfirmed(invoice) - navController.navigateTo( - ReceiveRoute.QR - ) { popUpTo(ReceiveRoute.QR) { inclusive = true } } - }, - onBack = { navController.popBackStack() }, - ) - } - } - composableWithDefaultTransitions { - cjitSessionState.entryDetails?.let { entryDetails -> - ReceiveConfirmScreen( - entry = entryDetails, - onLearnMore = { navController.navigateTo(ReceiveRoute.LiquidityAdditional) }, - onContinue = { invoice -> - wallet.updateOnchainBip21Amount(entryDetails.receiveAmountSats.toULong()) - cjitSessionState.onCjitConfirmed(invoice) - navController.navigateTo( - ReceiveRoute.QR - ) { popUpTo(ReceiveRoute.QR) { inclusive = true } } - }, - isAdditional = true, - onBack = { navController.popBackStack() }, - ) - } - } - composableWithDefaultTransitions { - cjitSessionState.entryDetails?.let { entryDetails -> - val context = LocalContext.current - val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() - val onNotificationSwitchClick = rememberNotificationToggleClick( - isGranted = notificationsGranted, - onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, - onOpenSystemSettings = { context.openNotificationSettings() }, - ) - - ReceiveLiquidityScreen( - entry = entryDetails, - onContinue = { navController.popBackStack() }, - onBack = { navController.popBackStack() }, - hasNotificationPermission = notificationsGranted, - onSwitchClick = onNotificationSwitchClick, - ) - } - } - composableWithDefaultTransitions { - cjitSessionState.entryDetails?.let { entryDetails -> - val context = LocalContext.current - val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() - val onNotificationSwitchClick = rememberNotificationToggleClick( - isGranted = notificationsGranted, - onPermissionResult = { granted -> settingsViewModel.setNotificationPreference(granted) }, - onOpenSystemSettings = { context.openNotificationSettings() }, - ) - - ReceiveLiquidityScreen( - entry = entryDetails, - onContinue = { navController.popBackStack() }, - isAdditional = true, - onBack = { navController.popBackStack() }, - hasNotificationPermission = notificationsGranted, - onSwitchClick = onNotificationSwitchClick, - ) - } - } composableWithDefaultTransitions { val walletUiState by wallet.walletState.collectAsStateWithLifecycle() @Suppress("ViewModelForwarding") EditInvoiceScreen( amountInputViewModel = editInvoiceAmountViewModel, walletUiState = walletUiState, - lightningState = lightningState, sourceTab = editInvoiceSourceTab, onBack = { navController.popBackStack() }, - updateInvoice = { - if (!cjitSessionState.hasConfirmedInvoiceForAmount(it)) { - cjitSessionState.clear() - wallet.updateBip21Invoice(it) - } else { - wallet.updateOnchainBip21Amount(it) - } - }, + updateInvoice = wallet::updateBip21Invoice, onClickAddTag = { navController.navigateTo(ReceiveRoute.AddTag) }, onClickTag = wallet::removeTag, onDescriptionUpdate = wallet::updateBip21Description, @@ -408,17 +297,9 @@ fun ReceiveSheet( isEditingPaymentRequestAmount = false navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, - navigateReceiveConfirm = { entry -> - cjitSessionState.onCjitCreated(entry) - navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) - }, onchainOnly = invoiceEditState.isHardwareInvoice, updateOnchainInvoice = wallet::setBip21AmountSats, - navigateCjitAmount = { - isAdditionalLiquidityAmountEntry = true - navController.navigateTo(ReceiveRoute.Amount) - }, - navigateGeoBlock = { navController.navigateTo(ReceiveRoute.GeoBlock) }, + navigateTransferUnavailable = { navController.navigateTo(ReceiveRoute.TransferUnavailable) }, ) } composableWithDefaultTransitions { @@ -453,34 +334,6 @@ fun ReceiveSheet( } } -@Stable -internal class ReceiveCjitSessionState { - var cjitInvoice by mutableStateOf(null) - private set - var entryDetails by mutableStateOf(null) - private set - private var confirmedAmountSats by mutableStateOf(null) - - fun onCjitCreated(entry: CjitEntryDetails) { - entryDetails = entry - } - - fun onCjitConfirmed(invoice: String) { - cjitInvoice = invoice - confirmedAmountSats = entryDetails?.receiveAmountSats?.toULong() - } - - fun hasConfirmedInvoiceForAmount(amountSats: ULong?): Boolean { - return cjitInvoice != null && confirmedAmountSats == amountSats - } - - fun clear() { - cjitInvoice = null - entryDetails = null - confirmedAmountSats = null - } -} - @Stable internal class ReceiveInvoiceEditState { var isHardwareInvoice by mutableStateOf(false) @@ -525,21 +378,6 @@ sealed interface ReceiveRoute { @Serializable data object QR : DeepLinkStart - @Serializable - data object Amount : DeepLinkStart - - @Serializable - data object Confirm : InternalOnly - - @Serializable - data object ConfirmIncreaseInbound : InternalOnly - - @Serializable - data object Liquidity : InternalOnly - - @Serializable - data object LiquidityAdditional : InternalOnly - @Serializable data object EditInvoice : DeepLinkStart @@ -561,15 +399,14 @@ sealed interface ReceiveRoute { data object PaymentRequestSent : InternalOnly @Serializable - data object GeoBlock : DeepLinkStart + data object TransferUnavailable : DeepLinkStart companion object { private val DEEP_LINK_STARTS: List = listOf( QR, - Amount, EditInvoice, AddTag, - GeoBlock, + TransferUnavailable, ) fun fromDeepLink(path: String): DeepLinkStart? = diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/TransferUnavailableScreen.kt similarity index 76% rename from app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt rename to app/src/main/java/to/bitkit/ui/screens/wallets/receive/TransferUnavailableScreen.kt index f2a15691f5..6c62cf1f8f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/LocationBlockScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/TransferUnavailableScreen.kt @@ -11,16 +11,16 @@ import to.bitkit.ui.sheets.TransferUnavailableContent import to.bitkit.ui.theme.AppThemeSurface @Composable -fun LocationBlockScreen( - onBackPressed: () -> Unit, - navigateAdvancedSetup: () -> Unit, +fun TransferUnavailableScreen( + onBack: () -> Unit, + onContinue: () -> Unit, modifier: Modifier = Modifier, ) { TransferUnavailableContent( navTitle = stringResource(R.string.lightning__transfer_unavailable__nav_title), description = stringResource(R.string.lightning__transfer_unavailable__text), - onContinue = navigateAdvancedSetup, - onBack = onBackPressed, + onContinue = onContinue, + onBack = onBack, modifier = modifier, ) } @@ -30,9 +30,9 @@ fun LocationBlockScreen( private fun Preview() { AppThemeSurface { BottomSheetPreview { - LocationBlockScreen( - onBackPressed = {}, - navigateAdvancedSetup = {}, + TransferUnavailableScreen( + onBack = {}, + onContinue = {}, modifier = Modifier.sheetHeight(), ) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt deleted file mode 100644 index eb27d3bb45..0000000000 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftLoading.kt +++ /dev/null @@ -1,149 +0,0 @@ -package to.bitkit.ui.sheets - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import to.bitkit.R -import to.bitkit.models.BITCOIN_SYMBOL -import to.bitkit.models.PrimaryDisplay -import to.bitkit.models.formatToModernDisplay -import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight -import to.bitkit.ui.components.MoneySSB -import to.bitkit.ui.components.SheetSize -import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.currencyViewModel -import to.bitkit.ui.scaffold.SheetTopBar -import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.Colors - -@Composable -fun GiftLoading(amount: ULong) { - Content(amount = amount) -} - -@Composable -private fun Content( - amount: ULong, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .sheetHeight(SheetSize.LARGE) - .gradientBackground() - .navigationBarsPadding() - .padding(horizontal = 16.dp) - ) { - SheetTopBar(titleText = stringResource(R.string.other__gift__claiming__title)) - VerticalSpacer(16.dp) - - val currencies = LocalCurrencies.current - val currency = currencyViewModel - val primaryDisplay = currencies.primaryDisplay - - if (primaryDisplay == PrimaryDisplay.BITCOIN) { - MoneySSB( - sats = amount.toLong(), - unit = PrimaryDisplay.FIAT, - color = Colors.White64, - showSymbol = true, - modifier = Modifier.align(Alignment.Start), - ) - VerticalSpacer(16.dp) - val bitcoinAmount = remember(amount, currency, currencies) { - currency?.convert(amount.toLong())?.bitcoinDisplay(currencies.displayUnit)?.value - ?: amount.formatToModernDisplay() - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.Start), - ) { - Display( - text = BITCOIN_SYMBOL, - fontWeight = FontWeight.ExtraBold, - color = Colors.White, - modifier = Modifier.padding(end = 6.dp), - ) - Display( - text = bitcoinAmount, - color = Colors.White, - ) - } - } else { - MoneySSB( - sats = amount.toLong(), - unit = PrimaryDisplay.BITCOIN, - color = Colors.White64, - showSymbol = true, - modifier = Modifier.align(Alignment.Start), - ) - VerticalSpacer(16.dp) - val fiatAmount = remember(amount, currency) { - currency?.convert(amount.toLong())?.formatted ?: "" - } - val fiatSymbol = remember(amount, currency) { - currency?.convert(amount.toLong())?.symbol ?: "" - } - if (fiatAmount.isNotEmpty()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.Start), - ) { - Display( - text = fiatSymbol, - fontWeight = FontWeight.ExtraBold, - color = Colors.White, - modifier = Modifier.padding(end = 6.dp), - ) - Display( - text = fiatAmount, - color = Colors.White, - ) - } - } - } - VerticalSpacer(32.dp) - - BodyM( - text = stringResource(R.string.other__gift__claiming__text), - color = Colors.White64, - ) - - FillHeight() - - Image( - painter = painterResource(R.drawable.gift), - contentDescription = null, - modifier = Modifier - .fillMaxWidth(IMAGE_WIDTH_FRACTION) - .aspectRatio(1.0f) - .align(Alignment.CenterHorizontally) - ) - - VerticalSpacer(32.dp) - - CircularProgressIndicator( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 32.dp) - .testTag("GiftLoading") - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt deleted file mode 100644 index dd3bc8d99b..0000000000 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftSheet.kt +++ /dev/null @@ -1,97 +0,0 @@ -package to.bitkit.ui.sheets - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController -import to.bitkit.R -import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.ui.components.Sheet -import to.bitkit.ui.navigateTo -import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.utils.composableWithDefaultTransitions -import to.bitkit.viewmodels.AppViewModel - -@Composable -fun GiftSheet( - sheet: Sheet.Gift, - appViewModel: AppViewModel, - modifier: Modifier = Modifier, - viewModel: GiftViewModel = hiltViewModel(), -) { - val navController = rememberNavController() - - LaunchedEffect(sheet.code, sheet.amount) { - viewModel.initialize(sheet.code, sheet.amount) - } - - val onSuccessState = rememberUpdatedState { details: NewTransactionSheetDetails -> - appViewModel.hideSheet() - appViewModel.showTransactionSheet(details) - } - - LaunchedEffect(Unit) { - viewModel.successEvent.collect { details -> - onSuccessState.value.invoke(details) - } - } - - LaunchedEffect(Unit) { - viewModel.navigationEvent.collect { route -> - when (route) { - is GiftRoute.Success -> appViewModel.hideSheet() - else -> navController.navigateTo(route) { - popUpTo(GiftRoute.Loading) { inclusive = false } - } - } - } - } - - Column( - modifier = modifier - .fillMaxWidth() - .sheetHeight() - .imePadding() - .testTag("GiftSheet") - ) { - NavHost( - navController = navController, - startDestination = GiftRoute.Loading, - ) { - composableWithDefaultTransitions { - GiftLoading(amount = sheet.amount) - } - composableWithDefaultTransitions { - GiftErrorSheet( - titleRes = R.string.other__gift__used__title, - textRes = R.string.other__gift__used__text, - testTag = "GiftUsed", - onDismiss = { appViewModel.hideSheet() }, - ) - } - composableWithDefaultTransitions { - GiftErrorSheet( - titleRes = R.string.other__gift__used_up__title, - textRes = R.string.other__gift__used_up__text, - testTag = "GiftUsedUp", - onDismiss = { appViewModel.hideSheet() }, - ) - } - composableWithDefaultTransitions { - GiftErrorSheet( - titleRes = R.string.other__gift__error__title, - textRes = R.string.other__gift__error__text, - testTag = "GiftError", - onDismiss = { appViewModel.hideSheet() }, - ) - } - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt deleted file mode 100644 index b6e8b75f7b..0000000000 --- a/app/src/main/java/to/bitkit/ui/sheets/GiftViewModel.kt +++ /dev/null @@ -1,155 +0,0 @@ -package to.bitkit.ui.sheets - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.LightningActivity -import com.synonym.bitkitcore.PaymentState -import com.synonym.bitkitcore.PaymentType -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import to.bitkit.di.BgDispatcher -import to.bitkit.ext.create -import to.bitkit.ext.nowTimestamp -import to.bitkit.models.NewTransactionSheetDetails -import to.bitkit.models.NewTransactionSheetDirection -import to.bitkit.models.NewTransactionSheetType -import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.GiftClaimResult -import to.bitkit.utils.Logger -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds - -@HiltViewModel -class GiftViewModel @Inject constructor( - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val blocktankRepo: BlocktankRepo, - private val activityRepo: ActivityRepo, -) : ViewModel() { - - private val _navigationEvent = MutableSharedFlow(extraBufferCapacity = 1) - val navigationEvent = _navigationEvent.asSharedFlow() - - private val _successEvent = MutableSharedFlow(extraBufferCapacity = 1) - val successEvent = _successEvent.asSharedFlow() - - private var code: String = "" - var amount: ULong = 0uL - private set - - @Volatile - private var isClaiming: Boolean = false - - fun initialize(code: String, amount: ULong) { - if (!isClaiming) { - viewModelScope.launch { - _navigationEvent.emit(GiftRoute.Loading) - } - } - this.code = code - this.amount = amount - viewModelScope.launch(bgDispatcher) { - claimGift() - } - } - - private suspend fun claimGift() = withContext(bgDispatcher) { - if (isClaiming) { - Logger.debug("Gift claim already in progress, skipping", context = TAG) - return@withContext - } - isClaiming = true - - try { - Logger.debug("Claiming gift: code=$code, amount=$amount", context = TAG) - blocktankRepo.claimGiftCode( - code = code, - amount = amount, - waitTimeout = NODE_STARTUP_TIMEOUT_MS.milliseconds, - ).fold( - onSuccess = { - Logger.debug("Gift claim successful: $it", context = TAG) - if (it is GiftClaimResult.SuccessWithoutLiquidity) { - insertGiftActivity(it.paymentHashOrTxId, it.sats, it.invoice, it.code) - } - _successEvent.emit( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.RECEIVED, - paymentHashOrTxId = it.paymentHashOrTxId, - sats = it.sats, - ) - ) - _navigationEvent.emit(GiftRoute.Success) - }, - onFailure = { handleGiftClaimError(it) }, - ) - } finally { - isClaiming = false - } - } - - private suspend fun insertGiftActivity( - paymentHashOrTxId: String, - sats: Long, - invoice: String, - code: String, - ) { - val nowTimestamp = nowTimestamp().epochSecond.toULong() - - val lightningActivity = LightningActivity.create( - id = paymentHashOrTxId, - txType = PaymentType.RECEIVED, - status = PaymentState.SUCCEEDED, - value = sats.toULong(), - invoice = invoice, - timestamp = nowTimestamp, - message = code, - ) - - activityRepo.insertActivity(Activity.Lightning(lightningActivity)).getOrThrow() - } - - private suspend fun handleGiftClaimError(error: Throwable) { - Logger.error("Gift claim failed", error, context = TAG) - - val route = when { - errorContains(error, "GIFT_CODE_ALREADY_USED") -> { - Logger.info("Gift code was already used", context = TAG) - GiftRoute.Used - } - errorContains(error, "GIFT_CODE_USED_UP") -> { - Logger.info("Gift code promotion depleted", context = TAG) - GiftRoute.UsedUp - } - else -> { - Logger.error("Unhandled gift claim error type", error, context = TAG) - GiftRoute.Error - } - } - - _navigationEvent.emit(route) - } - - private fun errorContains(error: Throwable, text: String): Boolean { - var currentError: Throwable? = error - while (currentError != null) { - val errorText = currentError.toString() + (currentError.message ?: "") - if (errorText.contains(text, ignoreCase = true)) { - return true - } - currentError = currentError.cause - } - return false - } - - companion object { - private const val TAG = "GiftViewModel" - private const val NODE_STARTUP_TIMEOUT_MS = 30_000L - } -} diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 9aa0542d2d..2b68308881 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -21,13 +21,6 @@ sealed class ServiceError(message: String) : AppError(message) { class NodeStillRunning : ServiceError("Node is still running") class NodeReleaseTimeout : ServiceError("Previous node release did not finish in time") class InvalidNodeSigningMessage : ServiceError("Invalid node signing message") - class CurrencyRateUnavailable : ServiceError("Currency rate unavailable") - class BlocktankInfoUnavailable : ServiceError("Blocktank info not available") - class ChannelSizeExceedsMaximum : ServiceError("Channel size exceeds maximum") - class CjitQuoteInvalid : ServiceError("CJIT quote is invalid") - class GeoBlocked : ServiceError("Geo blocked user") - class GiftClaimPaymentNotReceived : ServiceError("Gift claim payment not received") - class NodeCapacityUnavailable : ServiceError("Additional spending capacity is unavailable") } class HttpError(message: String, val code: Int = 500, cause: Throwable? = null) : AppError(message, cause) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 33f69b5e4a..c3367d18d7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -19,7 +19,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter -import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.LnurlAuthData @@ -332,9 +331,6 @@ class AppViewModel @Inject constructor( var splashVisible by mutableStateOf(true) private set - val isGeoBlocked = lightningRepo.lightningState.map { it.isGeoBlocked } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val forceCloseRemainingDuration = transferRepo.forceCloseRemainingDuration .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) @@ -645,9 +641,6 @@ class AppViewModel @Inject constructor( delay(AUTH_CHECK_SPLASH_DELAY_MS) splashVisible = false } - viewModelScope.launch { - lightningRepo.updateGeoBlockState() - } viewModelScope.launch { quickPayRepo.unhandledFailures.collect { notifyPaymentFailed((it as? QuickPayPaymentFailedError)?.reason) @@ -4610,7 +4603,7 @@ class AppViewModel @Inject constructor( is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data, fromMainScanner) } is Scanner.LnurlChannel -> handleNonPaymentScan { onScanLnurlChannel(scan.data) } is Scanner.NodeId -> handleNonPaymentScan { onScanNodeId(scan) } - is Scanner.Gift -> handleNonPaymentScan { onScanGift(scan.code, scan.amount) } + is Scanner.Gift -> handleNonPaymentScan { onScanGift() } else -> { val hasIncomingPaymentRequest = clearIncomingPaymentRequestTarget() val logMessage = if (hasIncomingPaymentRequest) { @@ -5461,9 +5454,9 @@ class AppViewModel @Inject constructor( mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) } - private fun onScanGift(code: String, amount: ULong) { + private fun onScanGift() { hideSheet() // hide scan sheet if opened - showSheet(Sheet.Gift(code = code, amount = amount)) + showSheet(Sheet.TransferUnavailable) } private suspend fun handleQuickPayIfApplicable( @@ -6049,7 +6042,7 @@ class AppViewModel @Inject constructor( approvePrepared: suspend (OnchainPreparedReceipt, List) -> Unit = { _, _ -> }, ): Result { // Blocktank no longer sells channels, so an unpaid order is never paid again. - if (original.orderId != null && lightningRepo.isTransferBlocked()) { + if (original.orderId != null) { Logger.warn("Stopped retrying a transfer to spending: transfers are unavailable", context = TAG) showSheet(Sheet.TransferUnavailable) return Result.failure( @@ -6088,15 +6081,6 @@ class AppViewModel @Inject constructor( attempt.attemptId == original.attemptId && attempt.requestId == original.requestId && attempt.orderId == original.orderId && attempt.payerIdentity == original.payerIdentity ) - attempt.orderId?.let { orderId -> - val order = blocktankRepo.fetchOrders(listOf(orderId)).getOrThrow().singleOrNull { it.id == orderId } - check(order != null && order.payment?.onchain?.address == attempt.address) - val originalContext = requireNotNull(attempt.transferContext) - check(order.state2 == BtOrderState2.CREATED) - check(order.clientBalanceSat == originalContext.originalOrderClientBalanceSats) - check(order.feeSat == originalContext.originalOrderFeeSats) - check(kotlin.time.Instant.parse(order.orderExpiresAt) > kotlin.time.Clock.System.now()) - } if (request != null) { val currentRequest = ( @@ -6139,22 +6123,6 @@ class AppViewModel @Inject constructor( } } - fun showPendingTransfer(attempt: OnchainSendAttempt) { - if (!attempt.isTransfer || attempt.orderId == null || !attempt.blocksNextSend) return - showSheet( - Sheet.Send( - SendRoute.Pending( - paymentHash = attempt.txid.orEmpty(), - amount = attempt.amountSats.toLong(), - observeResolution = false, - isOnchain = true, - walletId = attempt.walletId, - refusalReason = attempt.refusalReason, - ) - ) - ) - } - private fun showUnresolvedOnchainSend( txid: String?, amount: ULong, @@ -8047,8 +8015,7 @@ class AppViewModel @Inject constructor( showPairingCodeSheet(requestId) } - private fun isHighPrioritySheet(sheet: Sheet?) = sheet is Sheet.Gift || - sheet is Sheet.Send || + private fun isHighPrioritySheet(sheet: Sheet?) = sheet is Sheet.Send || sheet is Sheet.BTCPayConnection || sheet is Sheet.LnurlAuth || sheet is Sheet.Pin || diff --git a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt index 2a973c91bc..45c2169475 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt @@ -37,30 +37,13 @@ class BlocktankViewModel @Inject constructor( .distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - val minCjitSats: StateFlow = blocktankRepo.blocktankState - .map { it.minCjitSats } - .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - fun refreshOrders() { viewModelScope.launch { blocktankRepo.refreshOrders() } } - suspend fun createCjit(amountSats: ULong): IcJitEntry { - return blocktankRepo.createCjit(amountSats).getOrThrow() - } - suspend fun openChannel(orderId: String): IBtOrder { return blocktankRepo.openChannel(orderId).getOrThrow() } - - suspend fun refreshMinCjitSats() { - blocktankRepo.refreshMinCjitSats() - } - - suspend fun maxCjitAmountSats(): ULong? { - return blocktankRepo.maxCjitAmountSats().getOrThrow() - } } diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 5b3bdf5964..4dc902cbac 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -106,9 +106,6 @@ class SettingsViewModel @Inject constructor( } } - val hasSeenSpendingIntro = settingsStore.data.map { it.hasSeenSpendingIntro } - .asStateFlow(initialValue = false) - val notificationsGranted = settingsStore.data.map { it.notificationsGranted } .asStateFlow(initialValue = false) @@ -127,12 +124,6 @@ class SettingsViewModel @Inject constructor( } } - fun setHasSeenSpendingIntro(value: Boolean) { - viewModelScope.launch { - settingsStore.update { it.copy(hasSeenSpendingIntro = value) } - } - } - val hasSeenWidgetsIntro = settingsStore.data.map { it.hasSeenWidgetsIntro } .asStateFlow(initialValue = false) @@ -142,15 +133,6 @@ class SettingsViewModel @Inject constructor( } } - val hasSeenTransferIntro = settingsStore.data.map { it.hasSeenTransferIntro } - .asStateFlow(initialValue = false) - - fun setHasSeenTransferIntro(value: Boolean) { - viewModelScope.launch { - settingsStore.update { it.copy(hasSeenTransferIntro = value) } - } - } - val hasSeenSavingsIntro = settingsStore.data.map { it.hasSeenSavingsIntro } .asStateFlow(initialValue = false) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 2c7aaab98c..2c9afacd1f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -6,26 +6,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.BoltzPairInfo import com.synonym.bitkitcore.BoltzSwapEvent -import com.synonym.bitkitcore.BtOrderState2 -import com.synonym.bitkitcore.FeeRates -import com.synonym.bitkitcore.IBtOrder import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first @@ -34,56 +25,23 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId -import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsStore -import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose -import to.bitkit.ext.isBroadcastConnectivityFailure -import to.bitkit.ext.isHwDeviceBusy -import to.bitkit.ext.isHwFirmwareError -import to.bitkit.ext.isHwSessionFailure -import to.bitkit.ext.isHwUserCancellation import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toUserMessage -import to.bitkit.models.BITCOIN_SYMBOL -import to.bitkit.models.HwFundingBroadcastResult -import to.bitkit.models.HwFundingSignedTx -import to.bitkit.models.HwFundingTransaction -import to.bitkit.models.HwWallet -import to.bitkit.models.PrimaryDisplay import to.bitkit.models.Toast -import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType -import to.bitkit.models.WalletScope -import to.bitkit.models.formatMoney -import to.bitkit.models.safe -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.CurrencyRepo -import to.bitkit.repositories.HwPassphraseMismatchError -import to.bitkit.repositories.HwPassphraseRequiredError -import to.bitkit.repositories.HwWalletMismatchError -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo -import to.bitkit.repositories.OnchainSendOutcome -import to.bitkit.repositories.OnchainTransferContext import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.AppError -import to.bitkit.utils.HwErrorPresenter import to.bitkit.utils.Logger import javax.inject.Inject -import kotlin.math.min import kotlin.math.roundToLong import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes @@ -98,1546 +56,20 @@ const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms class TransferViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, - private val blocktankRepo: BlocktankRepo, - private val hwWalletRepo: HwWalletRepo, private val walletRepo: WalletRepo, - private val settingsStore: SettingsStore, - private val cacheStore: CacheStore, private val transferRepo: TransferRepo, private val boltzService: BoltzService, - private val currencyRepo: CurrencyRepo, private val clock: Clock, ) : ViewModel() { - /** Paired hardware wallets, for screens that render vendor-specific device visuals. */ - val hardwareWallets: StateFlow> - get() = hwWalletRepo.wallets - private val _spendingUiState = MutableStateFlow(TransferToSpendingUiState()) - val spendingUiState = _spendingUiState.asStateFlow() - private val _isForceTransferLoading = MutableStateFlow(false) val isForceTransferLoading = _isForceTransferLoading.asStateFlow() - val lightningSetupStep: StateFlow = settingsStore.data.map { it.lightningSetupStep } - .stateIn(viewModelScope, SharingStarted.Lazily, 0) - - val isNodeRunning = lightningRepo.lightningState.map { it.nodeStatus?.isRunning ?: false } - .stateIn(viewModelScope, SharingStarted.Lazily, false) - private val _selectedChannelIdsState = MutableStateFlow>(emptySet()) val selectedChannelIdsState = _selectedChannelIdsState.asStateFlow() - private val _transferValues = MutableStateFlow(TransferValues()) - val transferValues = _transferValues.asStateFlow() - - val transferEffects = MutableSharedFlow() - fun setTransferEffect(effect: TransferEffect) = viewModelScope.launch { transferEffects.emit(effect) } - private var hwTransferSignJob: Job? = null - private var hwTransferAttempt = 0 - private var hwFeeEstimateJob: Job? = null - private var confirmFeeJob: Job? = null - private var confirmPayJob: Job? = null - private var receivingFeeQuoteJob: Job? = null - private var advancedLimitsJob: Job? = null - private var spendingConfirmFundingPlan: SpendingConfirmFundingPlan? = null - private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null - private var activeHwTransferWalletId: String? = null - - fun onConfirmAmount(satsAmount: Long) { - if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return - val values = blocktankRepo.calculateLiquidityOptions(satsAmount.toULong()).getOrNull() - if (values == null || values.maxLspBalanceSat == 0uL) { - setTransferEffect( - TransferEffect.ToastError( - title = context.getString(R.string.lightning__spending_amount__error_max__title), - description = context.getString( - R.string.lightning__spending_amount__error_max__description_zero - ), - ) - ) - return - } - - val lspBalance = maxOf(values.defaultLspBalanceSat, values.minLspBalanceSat) - - viewModelScope.launch { - _spendingUiState.update { it.copy(isLoading = true) } - - withTimeoutOrNull(1.minutes) { - isNodeRunning.first { it } - } - - val feeSat = estimateSpendingFee( - clientBalanceSat = satsAmount.toULong(), - lspBalanceSat = lspBalance, - ).getOrElse { e -> - setTransferEffect(TransferEffect.ToastException(e)) - delay(1.seconds) - _spendingUiState.update { it.copy(isLoading = false) } - return@launch - } - - if (!canFundOrder(feeSat)) { - Logger.info("Rejected spending amount '$satsAmount' over funding budget", context = TAG) - setTransferEffect( - TransferEffect.ToastError( - title = context.getString(R.string.lightning__spending_amount__error_balance__title), - description = context.getString( - R.string.lightning__spending_amount__error_balance__description - ), - ) - ) - _spendingUiState.update { it.copy(isLoading = false) } - return@launch - } - - onEstimateReady(satsAmount.toULong(), lspBalance, feeSat) - delay(1.seconds) - _spendingUiState.update { it.copy(isLoading = false) } - } - } - - private suspend fun estimateSpendingFee( - clientBalanceSat: ULong, - lspBalanceSat: ULong, - ): Result = blocktankRepo.estimateOrderFee( - spendingBalanceSats = clientBalanceSat, - receivingBalanceSats = lspBalanceSat, - ).map { estimate -> - (clientBalanceSat.safe() + estimate.networkFeeSat.safe()).safe() + estimate.serviceFeeSat.safe() - } - - fun updateLimits(satsAmount: Long = 0) { - updateTransferValues(satsAmount.toULong()) - updateAvailableAmount() - } - - fun onReceivingAmountChange(amount: Long) { - receivingFeeQuoteJob?.cancel() - receivingFeeQuoteJob = viewModelScope.launch { - _spendingUiState.update { it.copy(receivingAmount = amount, feeEstimate = null) } - - if (amount == 0L) return@launch - - val transferValues = _transferValues.value - if (transferValues.minLspBalance == 0uL) return@launch - - val isValid = amount.toULong() >= transferValues.minLspBalance && - amount.toULong() <= transferValues.maxLspBalance - - if (!isValid) return@launch - - val result = blocktankRepo.estimateOrderFee( - spendingBalanceSats = _spendingUiState.value.clientBalanceSat, - receivingBalanceSats = amount.toULong(), - ) - - result.fold( - onSuccess = { response -> - _spendingUiState.update { - it.copy(feeEstimate = response.feeSat.toLong()) - } - }, - onFailure = { error -> - Logger.error("Failed to estimate fee", error, context = TAG) - _spendingUiState.update { - it.copy(feeEstimate = null) - } - } - ) - } - } - - fun onSpendingAdvancedContinue(receivingAmountSats: Long) { - if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return - viewModelScope.launch { - runSuspendCatching { - val state = _spendingUiState.value - val feeSat = estimateSpendingFee( - clientBalanceSat = state.clientBalanceSat, - lspBalanceSat = receivingAmountSats.toULong(), - ).getOrThrow() - if (!canFundOrder(feeSat)) { - Logger.info("Rejected advanced capacity '$receivingAmountSats' over funding budget", context = TAG) - setTransferEffect( - TransferEffect.ToastError( - title = context.getString(R.string.lightning__spending_advanced__error_balance__title), - description = context.getString( - R.string.lightning__spending_advanced__error_balance__description - ), - ) - ) - return@runSuspendCatching - } - if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) { - return@runSuspendCatching - } - hwFeeEstimateJob?.cancel() - hwFeeEstimateJob = null - _spendingUiState.update { - it.copy( - lspBalanceSat = receivingAmountSats.toULong(), - feeSat = feeSat, - order = it.order.takeIf { order -> order?.lspBalanceSat == receivingAmountSats.toULong() }, - isAdvanced = true, - hwMiningFeeSats = 0uL, - ) - } - setTransferEffect(TransferEffect.OnQuoteReady) - }.onFailure { e -> - setTransferEffect(TransferEffect.ToastException(e)) - } - } - } - - fun prepareSpendingConfirmFunding() { - confirmFeeJob?.cancel() - confirmFeeJob = viewModelScope.launch { - _spendingUiState.update { it.copy(isConfirmFeeReady = false) } - val order = _spendingUiState.value.order - val target = SpendingFundingTarget( - feeSat = _spendingUiState.value.feeSat, - address = order?.fundingAddress ?: spendingSizingAddress() ?: return@launch, - orderId = order?.id, - ) - buildSpendingConfirmFundingPlan(target) - .onSuccess { plan -> - spendingConfirmFundingPlan = plan - _spendingUiState.update { it.withFundingPlan(plan) } - } - .onFailure { - spendingConfirmFundingPlan = null - _spendingUiState.update { - it.copy( - isConfirmFeeReady = false, - miningFeeSats = 0uL, - shouldUseSendAll = false, - spendableBalance = 0uL, - ) - } - Logger.error("Failed to prepare transfer funding fee", it, context = TAG) - if (it is AppError) { - ToastEventBus.send(it) - } else { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__try_again), - ) - } - } - } - } - - fun onTransferToSpendingConfirm() { - if (confirmPayJob?.isActive == true || _spendingUiState.value.isBusy) return - val state = _spendingUiState.value - if (state.feeSat == 0uL) return - - _spendingUiState.update { it.copy(isConfirmPaying = true) } - confirmPayJob = viewModelScope.launch { - try { - val paid = runSuspendCatching { - val order = getOrCreateSpendingOrder() ?: return@runSuspendCatching false - paySpendingConfirmOrder(order, shown = state) - }.onFailure { - Logger.error("Failed to pay spending confirm order", it, context = TAG) - ToastEventBus.send(it) - }.getOrDefault(false) - - if (paid) { - transferEffects.emit(TransferEffect.OnSpendingFundingPaid) - } else { - _spendingUiState.update { it.copy(isConfirmPaying = false) } - } - } finally { - confirmPayJob = null - } - } - } - - private suspend fun getOrCreateSpendingOrder(): IBtOrder? { - val state = _spendingUiState.value - state.order?.let { return it } - if (isTransferUnavailable()) return null - return blocktankRepo.createOrder( - spendingBalanceSats = state.clientBalanceSat, - receivingBalanceSats = state.lspBalanceSat, - ).getOrElse { - ToastEventBus.send(it) - return null - }.also { created -> _spendingUiState.update { it.copy(order = created) } } - } - - private suspend fun ensureSpendingOrder(): IBtOrder? { - val order = getOrCreateSpendingOrder() ?: return null - if (order.feeSat > _spendingUiState.value.feeSat) { - spendingConfirmFundingPlan = null - _spendingUiState.update { it.copy(feeSat = order.feeSat) } - return null - } - return order - } - - private suspend fun spendingSizingAddress(): String? = - walletRepo.getAddresses(count = 1).onFailure { ToastEventBus.send(it) }.getOrNull()?.firstOrNull()?.address - - private suspend fun paySpendingConfirmOrder(order: IBtOrder, shown: TransferToSpendingUiState): Boolean { - val previous = lightningRepo.currentOnchainSendAttempt() - if (previous?.orderId == order.id) { - val txid = previous.txid - if (previous.hasPositiveEvidence && txid != null) { - if (!previous.localFollowupComplete) { - val original = requireNotNull(previous.transferContext) { - "Original funding context is unavailable" - } - check(order.payment?.onchain?.address == previous.address) { - "Original funding order address changed" - } - check( - order.clientBalanceSat == original.originalOrderClientBalanceSats && - order.feeSat == original.originalOrderFeeSats - ) { "Original funding order terms changed or are unavailable" } - withContext(NonCancellable) { - fundPaidOrder( - order = order, - txId = txid, - txTotalSats = original.txTotalSats, - preTransferOnchainSats = original.preTransferOnchainSats, - originalContext = original, - requireTransferPersisted = true, - ) - runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, txid) } - .onFailure { Logger.warn("Failed to finish accepted transfer locally", it, context = TAG) } - } - } - return true - } - transferEffects.emit(TransferEffect.OnFundingPending(previous)) - return false - } - if (cacheStore.data.first().paidOrders.containsKey(order.id)) return true - if (isTransferUnavailable()) return false - val plan = resolveSpendingConfirmPlan(order, shown) ?: return false - - Logger.debug( - "BT confirm: spendable=${plan.spendableBalance}, feeSat=${order.feeSat}, " + - "miningFee=${plan.miningFeeSats}, selectedUtxos=${plan.selectedUtxos?.size}, " + - "sendAll=${plan.shouldUseSendAll}", - context = TAG, - ) - - if (holdForFeesChange(order, shown, plan) || isSendAllBelowOrderFee(order, shown, plan)) return false - - val address = order.payment?.onchain?.address.orEmpty() - val transferContext = OnchainTransferContext( - txTotalSats = if (plan.shouldUseSendAll) { - plan.spendableBalance - } else { - order.feeSat.safe() + plan.miningFeeSats.safe() - }, - preTransferOnchainSats = plan.totalOnchainBalance, - originalOrderClientBalanceSats = order.clientBalanceSat, - originalOrderFeeSats = order.feeSat, - ) - return lightningRepo - .sendOnChain( - address = address, - sats = order.feeSat, - speed = TransactionSpeed.Fast, - utxosToSpend = if (plan.shouldUseSendAll) null else plan.selectedUtxos, - feeRates = plan.feeRates, - isTransfer = true, - channelId = order.channel?.shortChannelId, - isMaxAmount = plan.shouldUseSendAll, - orderId = order.id, - transferContext = transferContext, - ) - .fold( - onSuccess = { outcome -> - if (outcome !is OnchainSendOutcome.Accepted) { - showRetainedFundingPending() - return@fold false - } - // Survive ViewModel clearance between accepted broadcast and paid-order cache write. - withContext(NonCancellable) { - fundPaidOrder( - order = order, - txId = outcome.txid, - txTotalSats = transferContext.txTotalSats, - preTransferOnchainSats = transferContext.preTransferOnchainSats, - originalContext = transferContext, - requireTransferPersisted = true, - ) - runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, outcome.txid) } - .onFailure { Logger.warn("Failed to finish accepted transfer locally", it, context = TAG) } - } - true - }, - onFailure = { - if (!showRetainedFundingPending()) ToastEventBus.send(it) - false - } - ) - } - - private suspend fun isTransferUnavailable(): Boolean { - if (!lightningRepo.isTransferBlocked()) return false - Logger.warn("Skipped paying spending order: transfers are unavailable", context = TAG) - transferEffects.emit(TransferEffect.OnTransferUnavailable) - return true - } - - private suspend fun showRetainedFundingPending(): Boolean { - val attempt = lightningRepo.currentOnchainSendAttempt() ?: return false - if (!attempt.isTransfer || !attempt.blocksNextSend) return false - transferEffects.emit(TransferEffect.OnFundingPending(attempt)) - return true - } - - private suspend fun resolveSpendingConfirmPlan( - order: IBtOrder, - shown: TransferToSpendingUiState, - ): SpendingConfirmFundingPlan? { - val plan = spendingConfirmFundingPlan?.takeIf { it.orderId == order.id } - ?: buildSpendingConfirmFundingPlan(order).getOrElse { - if (holdForOrderFeeIncrease(order, shown)) return null - Logger.error("Failed to prepare transfer funding fee", it, context = TAG) - ToastEventBus.send(it) - return null - } - if (plan.miningFeeSats == 0uL) { - Logger.warn("Skipped paying order '${order.id}' without a mining fee estimate", context = TAG) - ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__try_again)) - return null - } - spendingConfirmFundingPlan = plan - return plan - } - - private suspend fun isSendAllBelowOrderFee( - order: IBtOrder, - shown: TransferToSpendingUiState, - plan: SpendingConfirmFundingPlan, - ): Boolean { - if (!plan.shouldUseSendAll || plan.maxSendable >= order.feeSat) return false - if (holdForOrderFeeIncrease(order, shown)) return true - - Logger.error( - "Insufficient balance for transfer: maxSendable=${plan.maxSendable}, " + - "orderFee=${order.feeSat}", - context = TAG, - ) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__pay_insufficient_savings), - ) - return true - } - - private suspend fun holdForOrderFeeIncrease(order: IBtOrder, shown: TransferToSpendingUiState): Boolean { - if (order.feeSat <= shown.feeSat) return false - Logger.info("Waiting for another swipe, order fee rose to '${order.feeSat}'", context = TAG) - spendingConfirmFundingPlan = null - _spendingUiState.update { it.copy(feeSat = order.feeSat) } - sendFeesChangedToast(isServiceFee = true, delta = order.feeSat.safe() - shown.feeSat.safe()) - return true - } - - private suspend fun holdForFeesChange( - order: IBtOrder, - shown: TransferToSpendingUiState, - plan: SpendingConfirmFundingPlan, - ): Boolean { - val payable = shown.withFundingPlan(plan, feeSat = order.feeSat) - val shownTotal = shown.confirmLeavingAmountSats - val payableTotal = payable.confirmLeavingAmountSats - if (payableTotal > shownTotal) { - Logger.info("Waiting for another swipe, total rose from '$shownTotal' to '$payableTotal'", context = TAG) - _spendingUiState.update { it.withFundingPlan(plan, feeSat = order.feeSat) } - sendFeesChangedToast( - isServiceFee = order.feeSat > shown.feeSat, - delta = payableTotal.safe() - shownTotal.safe(), - ) - return true - } - if (payableTotal != shownTotal) { - Logger.info("Paying rebuilt plan, total changed from '$shownTotal' to '$payableTotal'", context = TAG) - } - - return false - } - - private suspend fun sendFeesChangedToast(isServiceFee: Boolean, delta: ULong) { - val description = if (isServiceFee) { - R.string.lightning__spending_confirm__fees_changed_service - } else { - R.string.lightning__spending_confirm__fees_changed_network - } - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = context.getString(R.string.lightning__spending_confirm__fees_changed_title), - description = context.getString(description).replace("{amount}", formatPrimaryAmount(delta)), - ) - } - - private fun formatPrimaryAmount(sats: ULong): String { - val state = currencyRepo.currencyState.value - val fiat = currencyRepo.convertSatsToFiat(sats.toLong()).getOrNull() - if (state.primaryDisplay == PrimaryDisplay.FIAT && fiat != null) return fiat.formattedWithSymbol() - return "$BITCOIN_SYMBOL ${sats.formatMoney(state.displayUnit)}" - } - - private suspend fun buildSpendingConfirmFundingPlan(order: IBtOrder): Result = - buildSpendingConfirmFundingPlan( - SpendingFundingTarget(feeSat = order.feeSat, address = order.fundingAddress, orderId = order.id), - ) - - private suspend fun buildSpendingConfirmFundingPlan( - target: SpendingFundingTarget, - ): Result = runSuspendCatching { - require(target.address.isNotEmpty()) { "Funding address is empty" } - - val speed = TransactionSpeed.Fast - val balanceDetails = lightningRepo.getBalancesAsync().getOrThrow() - val spendableBalance = balanceDetails.spendableOnchainBalanceSats - val totalOnchainBalance = balanceDetails.totalOnchainBalanceSats - val feeRates = lightningRepo.getFeeRates().getOrThrow() - val satsPerVByte = lightningRepo.getFeeRateForSpeed(speed, feeRates).getOrThrow() - - resolveNormalSpendingConfirmFunding( - target = target, - speed = speed, - feeRates = feeRates, - satsPerVByte = satsPerVByte, - spendableBalance = spendableBalance, - totalOnchainBalance = totalOnchainBalance, - ) ?: resolveSendAllSpendingConfirmFunding( - target = target, - speed = speed, - feeRates = feeRates, - spendableBalance = spendableBalance, - totalOnchainBalance = totalOnchainBalance, - ) - } - - private suspend fun resolveNormalSpendingConfirmFunding( - target: SpendingFundingTarget, - speed: TransactionSpeed, - feeRates: FeeRates, - satsPerVByte: ULong, - spendableBalance: ULong, - totalOnchainBalance: ULong, - ): SpendingConfirmFundingPlan? { - val utxos = lightningRepo.selectUtxosWithAlgorithm( - targetAmountSats = target.feeSat, - satsPerVByte = satsPerVByte, - algorithm = CoinSelectionAlgorithm.LARGEST_FIRST, - ).getOrElse { - Logger.warn("Normal coin selection failed, using sendAll", it, context = TAG) - return null - } - - val normalFee = lightningRepo.calculateTotalFee( - amountSats = target.feeSat, - address = target.address, - speed = speed, - utxosToSpend = utxos, - feeRates = feeRates, - ).getOrElse { - Logger.warn("Failed to estimate transfer funding fee", it, context = TAG) - 0uL - } - val totalInput = utxos.fold(0uL) { acc, utxo -> acc.safe() + utxo.valueSats.safe() } - if (wouldCreateDustChange(totalInput = totalInput, amountSats = target.feeSat, normalFee = normalFee)) { - return null - } - - return SpendingConfirmFundingPlan( - orderId = target.orderId, - feeRates = feeRates, - miningFeeSats = normalFee, - shouldUseSendAll = false, - selectedUtxos = utxos, - spendableBalance = spendableBalance, - totalOnchainBalance = totalOnchainBalance, - maxSendable = 0uL, - ) - } - - private suspend fun resolveSendAllSpendingConfirmFunding( - target: SpendingFundingTarget, - speed: TransactionSpeed, - feeRates: FeeRates, - spendableBalance: ULong, - totalOnchainBalance: ULong, - ): SpendingConfirmFundingPlan { - val sendAllFee = lightningRepo.estimateSendAllFee( - address = target.address, - speed = speed, - feeRates = feeRates, - ).getOrThrow() - val maxSendable = spendableBalance.safe() - sendAllFee.safe() - if (maxSendable < target.feeSat) { - throw AppError(context.getString(R.string.other__pay_insufficient_savings)) - } - - return SpendingConfirmFundingPlan( - orderId = target.orderId, - feeRates = feeRates, - miningFeeSats = sendAllFee, - shouldUseSendAll = true, - selectedUtxos = null, - spendableBalance = spendableBalance, - totalOnchainBalance = totalOnchainBalance, - maxSendable = maxSendable, - ) - } - - private fun wouldCreateDustChange( - totalInput: ULong, - amountSats: ULong, - normalFee: ULong, - dustLimit: ULong = Defaults.dustLimit.toULong(), - ): Boolean { - val expectedChange = totalInput.toLong() - amountSats.toLong() - normalFee.toLong() - return expectedChange in 0 until dustLimit.toLong() - } - - /** Records a paid order and starts watching it, after the funding tx was broadcast (local or HW signed). */ - private suspend fun fundPaidOrder( - order: IBtOrder, - txId: String, - createTransferActivity: Boolean = false, - fee: ULong = 0uL, - feeRate: ULong = 0uL, - txTotalSats: ULong? = null, - preTransferOnchainSats: ULong? = null, - activityWalletId: String = WalletScope.default, - originalContext: OnchainTransferContext? = null, - requireTransferPersisted: Boolean = false, - ) { - if (requireTransferPersisted) { - transferRepo.persistAcceptedFunding(order, txId, requireNotNull(originalContext)).getOrThrow() - } else { - cacheStore.addPaidOrder(orderId = order.id, txId = txId) - transferRepo.createTransfer( - type = TransferType.TO_SPENDING, - amountSats = order.clientBalanceSat.toLong(), - fundingTxId = txId, - lspOrderId = order.id, - txTotalSats = txTotalSats?.toLong(), - preTransferOnchainSats = preTransferOnchainSats?.toLong(), - ) - } - if (createTransferActivity) { - transferRepo.createPendingToSpendingActivity( - order = order, - txId = txId, - fee = fee, - feeRate = feeRate, - walletId = activityWalletId, - ) - } - _spendingUiState.update { it.copy(order = null) } - viewModelScope.launch { walletRepo.syncBalances() } - viewModelScope.launch { watchOrder(order.id) } - } - - private suspend fun watchOrder(orderId: String): Result = runCatching { - Logger.debug("Started watching order: '$orderId'", context = TAG) - - // Step 0: Starting - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_0) } - Logger.debug("LN setup step: $LN_SETUP_STEP_0", context = TAG) - delay(MIN_STEP_DELAY_MS) - - // Poll until payment is confirmed (order state becomes PAID or EXECUTED) - val paidOrder = pollUntil(orderId) { order -> - order.state2 == BtOrderState2.PAID || order.state2 == BtOrderState2.EXECUTED - } ?: return Result.failure(Exception("Order not found or expired")) - - // Step 1: Payment confirmed - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_1) } - Logger.debug("LN setup step: $LN_SETUP_STEP_1", context = TAG) - delay(MIN_STEP_DELAY_MS) - - // Try to open channel (idempotent - safe to call multiple times) - blocktankRepo.openChannel(paidOrder.id) - - // Step 2: Channel opening requested - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_2) } - Logger.debug("LN setup step: $LN_SETUP_STEP_2", context = TAG) - delay(MIN_STEP_DELAY_MS) - - // Poll until channel is ready (EXECUTED state or channel has state) - pollUntil(orderId) { order -> - order.state2 == BtOrderState2.EXECUTED || order.channel?.state != null - } ?: return Result.failure(Exception("Order not found or expired")) - - // Step 3: Complete - transferRepo.syncTransferStates() - settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_3) } - Logger.debug("LN setup step: $LN_SETUP_STEP_3", context = TAG) - - Logger.debug("Order settled: '$orderId'", context = TAG) - return@runCatching true - }.onFailure { - Logger.error("Failed to watch order: '$orderId'", it, context = TAG) - }.also { - Logger.debug("Stopped watching order: '$orderId'", context = TAG) - } - - private suspend fun pollUntil(orderId: String, condition: (IBtOrder) -> Boolean): IBtOrder? { - var consecutiveErrors = 0 - - while (true) { - blocktankRepo.getOrder(orderId, refresh = true).fold( - onSuccess = { order -> - consecutiveErrors = 0 - - if (order == null) { - Logger.error("Order not found: '$orderId'", context = TAG) - return null - } - if (order.state2 == BtOrderState2.EXPIRED) { - Logger.error("Order expired: '$orderId'", context = TAG) - return null - } - if (condition(order)) { - return order - } - }, - onFailure = { - consecutiveErrors++ - Logger.warn( - "Failed to fetch order '$orderId' (attempt $consecutiveErrors/$MAX_CONSECUTIVE_ERRORS)", - it, - context = TAG - ) - - if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - Logger.error("Too many consecutive errors polling order '$orderId', giving up", context = TAG) - return null - } - } - ) - - delay(POLL_INTERVAL_MS) - } - } - - private suspend fun onEstimateReady(clientBalanceSat: ULong, lspBalanceSat: ULong, feeSat: ULong) { - settingsStore.update { it.copy(lightningSetupStep = 0) } - if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return - pendingHwFundingBroadcast = null - hwFeeEstimateJob?.cancel() - hwFeeEstimateJob = null - _spendingUiState.update { - it.copy( - clientBalanceSat = clientBalanceSat, - lspBalanceSat = lspBalanceSat, - feeSat = feeSat, - order = null, - isAdvanced = false, - isConfirmPaying = false, - hasPendingHwBroadcast = false, - hwMiningFeeSats = 0uL, - ) - } - setTransferEffect(TransferEffect.OnQuoteReady) - } - - private fun updateAvailableAmount() { - viewModelScope.launch { - _spendingUiState.update { it.copy(isLoading = true) } - - awaitNodeRunning() - - val fundingBudget = loadFundingBudget() - _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget, hwFundingWalletId = null) } - val availableAmount = fundingBudget ?: 0uL - - val initialLspFees = estimateInitialLspFees(availableAmount) - if (initialLspFees == null) { - _spendingUiState.update { it.copy(isLoading = false) } - return@launch - } - - val balanceAfterLspFee = availableAmount.safe() - initialLspFees.safe() - - estimateFinalMaxSendAmount(availableAmount, balanceAfterLspFee) - } - } - - private suspend fun awaitNodeRunning() { - withTimeoutOrNull(1.minutes) { - isNodeRunning.first { it } - } - } - - private suspend fun estimateInitialLspFees(availableAmount: ULong): ULong? { - val liquidity = blocktankRepo - .calculateLiquidityOptions(availableAmount) - .getOrNull() ?: return null - - val lspBalance = maxOf(liquidity.defaultLspBalanceSat, liquidity.minLspBalanceSat) - - val orderFee = blocktankRepo.estimateOrderFee( - spendingBalanceSats = availableAmount, - receivingBalanceSats = lspBalance, - ).getOrNull() ?: return null - - return orderFee.networkFeeSat.safe() + orderFee.serviceFeeSat.safe() - } - - private suspend fun estimateFinalMaxSendAmount( - availableAmount: ULong, - balanceAfterLspFee: ULong, - ) { - // An on-chain balance larger than the LSP's max channel size makes - // calculateLiquidityOptions report maxLspBalanceSat = 0 (the client balance already - // saturates the channel). Clamp the prospective client balance to the LSP's - // maxClientBalanceSat so the spendable amount caps at that limit instead of collapsing - // to zero, leaving the rest of the funds on-chain. - val lspMaxClientBalance = blocktankRepo.blocktankState.value.info?.options?.maxClientBalanceSat - val cappedClientBalance = lspMaxClientBalance - ?.let { max -> minOf(balanceAfterLspFee, max) } - ?: balanceAfterLspFee - - val liquidity = blocktankRepo.calculateLiquidityOptions(cappedClientBalance).getOrNull() - if (liquidity == null || liquidity.maxClientBalanceSat == 0uL) { - _spendingUiState.update { it.copy(isLoading = false, maxAllowedToSend = 0) } - return - } - - val receivingAmount = maxOf(liquidity.defaultLspBalanceSat, liquidity.minLspBalanceSat) - - blocktankRepo.estimateOrderFee( - spendingBalanceSats = cappedClientBalance, - receivingBalanceSats = receivingAmount, - ).onSuccess { estimate -> - val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() - val maxClientBalance = resolveAffordableClientBalance( - availableAmount = availableAmount, - quotedBalance = cappedClientBalance, - quotedFee = lspFees, - ) - val maxSend = min( - liquidity.maxClientBalanceSat.toLong(), - maxClientBalance.toLong() - ) - val quarterAmount = min((maxSend.toDouble() * 0.25).roundToLong(), maxSend) - - _spendingUiState.update { - it.copy( - maxAllowedToSend = maxSend, - isLoading = false, - balanceAfterFee = maxSend, - quarterAmount = quarterAmount, - ) - } - }.onFailure { - _spendingUiState.update { it.copy(isLoading = false) } - Logger.error("Failure", it, context = TAG) - setTransferEffect(TransferEffect.ToastException(it)) - } - } - - /** - * Largest client balance that still covers its own order fee, settled against live quotes. - * - * [quotedFee] prices [quotedBalance], but the advertised max is usually a different balance, and - * the LSP charges the client and LSP sides of the channel at different rates. The fee at that - * other balance can therefore be higher, leaving an order the user cannot fund. Each round - * re-quotes and steps down by the shortfall; the fee moves by a small fraction of a satoshi per - * satoshi of balance, so this settles well within [MAX_AFFORDABILITY_ROUNDS]. - */ - private suspend fun resolveAffordableClientBalance( - availableAmount: ULong, - quotedBalance: ULong, - quotedFee: ULong, - ): ULong { - var candidate = quotedBalance - var fee = quotedFee - repeat(MAX_AFFORDABILITY_ROUNDS) { - if (candidate.safe() + fee.safe() <= availableAmount) return candidate - candidate = availableAmount.safe() - fee.safe() - fee = quoteOrderFee(candidate) ?: run { - Logger.warn( - "Advertising unverified max '$candidate', fee quote unavailable", - context = TAG, - ) - return candidate - } - } - if (candidate.safe() + fee.safe() <= availableAmount) return candidate - - val fallback = availableAmount.safe() - fee.safe() - Logger.warn( - "Max '$candidate' still over budget '$availableAmount' after " + - "'$MAX_AFFORDABILITY_ROUNDS' rounds, advertising unverified '$fallback'", - context = TAG, - ) - return fallback - } - - private suspend fun quoteAdvancedOrderFee(clientBalance: ULong, receivingAmount: ULong): ULong? = - blocktankRepo.estimateOrderFee( - spendingBalanceSats = clientBalance, - receivingBalanceSats = receivingAmount, - ).getOrNull()?.feeSat - - private suspend fun resolveAffordableLspBalance( - clientBalance: ULong, - budget: ULong, - minLspBalance: ULong, - maxLspBalance: ULong, - ): ULong? { - val headroom = budget.safe() - clientBalance.safe() - val maxFee = quoteAdvancedOrderFee(clientBalance, maxLspBalance) ?: run { - Logger.warn("Advertising unsettled max capacity '$maxLspBalance', fee quote unavailable", context = TAG) - return maxLspBalance - } - if (maxFee <= headroom) return maxLspBalance - - val minFee = quoteAdvancedOrderFee(clientBalance, minLspBalance) - if (minFee == null || minFee > headroom) return null - - return settleCapacity( - clientBalance = clientBalance, - headroom = headroom, - affordable = minLspBalance, - affordableFee = minFee, - overBudget = maxLspBalance, - overBudgetFee = maxFee, - ) - } - - private suspend fun settleCapacity( - clientBalance: ULong, - headroom: ULong, - affordable: ULong, - affordableFee: ULong, - overBudget: ULong, - overBudgetFee: ULong, - ): ULong { - var settled = affordable - var settledFee = affordableFee - var ceiling = overBudget - var ceilingFee = overBudgetFee - repeat(MAX_AFFORDABILITY_ROUNDS) { - val feeSpan = ceilingFee.safe() - settledFee.safe() - if (feeSpan == 0uL) return settled - val span = ceiling.safe() - settled.safe() - val feeHeadroom = headroom.safe() - settledFee.safe() - val candidate = settled.safe() + ((span.safe() * feeHeadroom.safe()) / feeSpan).safe() - if (candidate <= settled) return settled - val candidateFee = quoteAdvancedOrderFee(clientBalance, candidate) ?: return settled - if (candidateFee <= headroom) { - settled = candidate - settledFee = candidateFee - } else { - ceiling = candidate - ceilingFee = candidateFee - } - } - return settled - } - - /** - * Order cost the on-chain balance can fund, or null when the balance itself is unreadable. - * - * Matches iOS by starting from raw spendable rather than `maxSendOnchainSats`, which has already - * reserved a default-tier send-all fee, and subtracting exactly one fast mining fee. - */ - private suspend fun loadFundingBudget(): ULong? { - val spendable = lightningRepo.getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats ?: return null - val miningFee = lightningRepo.estimateSendAllFee(speed = TransactionSpeed.Fast).getOrElse { - Logger.warn("Failed to estimate transfer mining fee reserve", it, context = TAG) - (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() - } - return spendable.safe() - miningFee.safe() - } - - private suspend fun currentFundingBudget(): ULong? { - val sizedBudget = _spendingUiState.value.fundingBudgetSats - val hwWalletId = _spendingUiState.value.hwFundingWalletId - val liveBudget = if (hwWalletId != null) loadHwFundingBudget(hwWalletId) else loadFundingBudget() - return liveBudget ?: sizedBudget - } - - private suspend fun loadHwFundingBudget(walletId: String): ULong? { - val balance = hwWalletRepo.getFundingAccount(walletId).getOrNull()?.balanceSats ?: return null - return balance.safe() - hwFundingFeeReserve(balance).safe() - } - - private suspend fun canFundOrder(feeSat: ULong): Boolean { - val budget = currentFundingBudget() - if (budget == null) { - Logger.warn("Skipped funding check, no sized budget available", context = TAG) - return true - } - val canFund = feeSat <= budget - if (!canFund) { - Logger.info("Priced order '$feeSat' over funding budget '$budget'", context = TAG) - } - return canFund - } - - private suspend fun quoteOrderFee(clientBalance: ULong): ULong? { - val liquidity = blocktankRepo.calculateLiquidityOptions(clientBalance).getOrNull() ?: return null - val receivingAmount = maxOf(liquidity.defaultLspBalanceSat, liquidity.minLspBalanceSat) - return blocktankRepo.estimateOrderFee( - spendingBalanceSats = clientBalance, - receivingBalanceSats = receivingAmount, - ).getOrNull()?.let { it.networkFeeSat.safe() + it.serviceFeeSat.safe() } - } - - fun onUseDefaultLspBalanceClick() { - if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return - viewModelScope.launch { - val state = _spendingUiState.value - val values = blocktankRepo.calculateLiquidityOptions(state.clientBalanceSat).getOrNull() - ?: return@launch - val lspBalance = maxOf(values.defaultLspBalanceSat, values.minLspBalanceSat) - estimateSpendingFee(state.clientBalanceSat, lspBalance) - .onSuccess { feeSat -> - if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return@onSuccess - hwFeeEstimateJob?.cancel() - _spendingUiState.update { - it.copy( - lspBalanceSat = lspBalance, - feeSat = feeSat, - order = it.order.takeIf { order -> order?.lspBalanceSat == lspBalance }, - isAdvanced = false, - isConfirmPaying = false, - hwMiningFeeSats = 0uL, - ) - } - } - .onFailure { ToastEventBus.send(it) } - } - } - - fun resetSpendingState() { - if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) { - return - } - hwTransferSignJob?.cancel() - hwTransferSignJob = null - hwFeeEstimateJob?.cancel() - hwFeeEstimateJob = null - confirmFeeJob?.cancel() - confirmFeeJob = null - spendingConfirmFundingPlan = null - pendingHwFundingBroadcast = null - activeHwTransferWalletId = null - _spendingUiState.update { TransferToSpendingUiState() } - _transferValues.update { TransferValues() } - } - - fun cancelHardwareTransfer() { - _spendingUiState.update { it.copy(isHwPassphraseRequired = false, isVerifyingHwPassphrase = false) } - if (pendingHwFundingBroadcast != null) return - val walletId = activeHwTransferWalletId - hwTransferSignJob?.cancel() - hwTransferSignJob = null - hwTransferAttempt++ - hwFeeEstimateJob?.cancel() - hwFeeEstimateJob = null - _spendingUiState.update { it.copy(isSigning = false, isConnectingDevice = false) } - if (walletId != null) { - viewModelScope.launch { - hwWalletRepo.disconnectStaleSession(walletId) - } - } - } - - // endregion - - // region Hardware Wallet - - fun updateHwLimits(walletId: String) { - viewModelScope.launch { - _spendingUiState.update { it.copy(isLoading = true) } - - val account = hwWalletRepo.getFundingAccount(walletId).getOrElse { - Logger.error("Failed to load hardware funding account", it, context = TAG) - _spendingUiState.update { s -> s.copy(isLoading = false, maxAllowedToSend = 0, balanceAfterFee = 0) } - setTransferEffect(TransferEffect.ToastException(it)) - return@launch - } - - awaitNodeRunning() - updateTransferValues(0uL) - - val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe() - _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount, hwFundingWalletId = walletId) } - - val initialLspFees = estimateInitialLspFees(availableAmount) - if (initialLspFees == null) { - _spendingUiState.update { it.copy(isLoading = false) } - return@launch - } - - val balanceAfterLspFee = availableAmount.safe() - initialLspFees.safe() - estimateFinalMaxSendAmount(availableAmount, balanceAfterLspFee) - } - } - - /** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */ - fun warmUpHardwareConnection(walletId: String) { - hwWalletRepo.warmUpKnownDevice(walletId) - } - - fun updateHwFundingFeeEstimate(walletId: String) { - hwFeeEstimateJob?.cancel() - hwFeeEstimateJob = viewModelScope.launch { - val state = _spendingUiState.value - if (state.hasPendingHwBroadcast) return@launch - if (state.feeSat == 0uL) return@launch - val address = state.order?.fundingAddress ?: spendingSizingAddress() ?: return@launch - if (address.isEmpty()) return@launch - - runSuspendCatching { - val satsPerVByte = hwFundingSatsPerVByte() - hwWalletRepo.composeFundingTransaction( - walletId = walletId, - address = address, - sats = state.feeSat, - satsPerVByte = satsPerVByte, - ).getOrThrow().miningFeeSats - }.onSuccess { miningFeeSats -> - _spendingUiState.update { current -> - if (current.feeSat != state.feeSat || current.hasPendingHwBroadcast) { - current - } else { - current.copy(hwMiningFeeSats = miningFeeSats) - } - } - }.onFailure { - Logger.debug( - "Skipped offline hardware funding fee estimate for '$walletId'", - context = TAG, - ) - } - } - } - - fun onTransferToSpendingHwConfirm(walletId: String) { - if (hwTransferSignJob?.isActive == true || _spendingUiState.value.isBusy) return - val state = _spendingUiState.value - if (state.feeSat == 0uL) return - - activeHwTransferWalletId = walletId - _spendingUiState.update { it.copy(isSigning = true) } - val attempt = ++hwTransferAttempt - hwTransferSignJob = viewModelScope.launch { - try { - val signedOrder = _spendingUiState.value.order - val isBroadcastRetry = signedOrder != null && - pendingHwFundingBroadcast?.matches(signedOrder, walletId, signedOrder.fundingAddress) == true - if (isTransferUnavailable()) return@launch - if (!isBroadcastRetry && hwWalletRepo.needsPassphrase(walletId)) { - _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } - return@launch - } - val order = ensureSpendingOrder() ?: return@launch - val address = order.fundingAddress - if (address.isEmpty()) { - ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) - return@launch - } - signAndBroadcastHardwareFunding(order, walletId, address) - .onSuccess { result -> - runSuspendCatching { - fundPaidOrder( - order = order, - txId = result.txId, - createTransferActivity = true, - fee = result.miningFeeSats, - feeRate = result.feeRate, - activityWalletId = walletId, - ) - }.onSuccess { - pendingHwFundingBroadcast = null - activeHwTransferWalletId = null - _spendingUiState.update { it.copy(hasPendingHwBroadcast = false) } - setTransferEffect(TransferEffect.OnHwTxSigned) - }.onFailure { - Logger.error("Failed to record broadcast hardware transfer", it, context = TAG) - handleHardwareTransferFailure(it, walletId) - } - } - .onFailure { handleHardwareTransferFailure(it, walletId) } - } finally { - // A cancelled job can outlive cancelHardwareTransfer() while a device call returns, and - // must not reset the state of a transfer attempt started after it. - if (hwTransferAttempt == attempt) { - _spendingUiState.update { it.copy(isSigning = false, isConnectingDevice = false) } - hwTransferSignJob = null - } - } - } - } - - fun onHwPassphraseSubmit(walletId: String, passphrase: String) { - if (passphrase.isEmpty() || hwTransferSignJob?.isActive == true) return - - hwTransferSignJob = viewModelScope.launch { - _spendingUiState.update { it.copy(isVerifyingHwPassphrase = true) } - val result = hwWalletRepo.reconnectWithPassphrase(walletId = walletId, passphrase = passphrase) - _spendingUiState.update { it.copy(isVerifyingHwPassphrase = false) } - hwTransferSignJob = null - result - .onSuccess { - if (!_spendingUiState.value.isHwPassphraseRequired) return@launch - _spendingUiState.update { it.copy(isHwPassphraseRequired = false) } - onTransferToSpendingHwConfirm(walletId) - } - .onFailure { handleHardwarePassphraseFailure(it, walletId) } - } - } - - /** Backing out of the prompt also drops the reopen it started, so no signature is requested. */ - fun onHwPassphraseDismiss() { - hwTransferSignJob?.cancel() - hwTransferSignJob = null - _spendingUiState.update { it.copy(isHwPassphraseRequired = false, isVerifyingHwPassphrase = false) } - } - - private suspend fun handleHardwarePassphraseFailure(e: Throwable, walletId: String) { - if (e is HwPassphraseMismatchError) { - Logger.warn("Rejected wrong passphrase for hardware wallet '$walletId'", context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = context.getString(R.string.hardware__passphrase_mismatch), - ) - return - } - handleHardwareTransferFailure(e, walletId) - } - - private suspend fun signAndBroadcastHardwareFunding( - order: IBtOrder, - walletId: String, - address: String, - ): Result { - val result = runCatching { - val signedTx = pendingHwFundingBroadcast - ?.takeIf { it.matches(order, walletId, address) } - ?.signedTx - ?.also { pending -> - _spendingUiState.update { state -> state.copy(hwMiningFeeSats = pending.miningFeeSats) } - } - ?: prepareSignedHardwareFunding(order, walletId, address).also { - pendingHwFundingBroadcast = PendingHwFundingBroadcast( - orderId = order.id, - walletId = walletId, - address = address, - amountSats = order.feeSat, - signedTx = it, - ) - _spendingUiState.update { state -> - state.copy( - hasPendingHwBroadcast = true, - hwMiningFeeSats = it.miningFeeSats, - ) - } - } - broadcastHardwareFunding(signedTx) - } - result.exceptionOrNull()?.rethrowIfCancellation() - return result - } - - private suspend fun prepareSignedHardwareFunding( - order: IBtOrder, - walletId: String, - address: String, - ): HwFundingSignedTx { - ensureHardwareConnected(walletId) - val satsPerVByte = hwFundingSatsPerVByte() - val funding = composeHardwareFundingTransaction( - walletId = walletId, - address = address, - sats = order.feeSat, - satsPerVByte = satsPerVByte, - ) - _spendingUiState.update { it.copy(hwMiningFeeSats = funding.miningFeeSats) } - return signHardwareFunding(walletId, funding) - } - - private suspend fun ensureHardwareConnected(walletId: String) { - // Nothing has been sent to the device for signing yet, so the screen may be left while this - // waits; a Jade may sit here for minutes waiting for its PIN. - val attempt = hwTransferAttempt - _spendingUiState.update { it.copy(isConnectingDevice = true) } - try { - runCatching { - // A Jade reconnect may include entering the PIN on the device, so the budget is per vendor. - withTimeout(hwWalletRepo.reconnectTimeout(walletId)) { - hwWalletRepo.ensureConnected(walletId).getOrThrow() - } - }.getOrElse { - it.rethrowIfCancellation() - if (it.isHwUserCancellation()) throw it - throw HardwareReconnectError(it) - } - } finally { - if (hwTransferAttempt == attempt) _spendingUiState.update { it.copy(isConnectingDevice = false) } - } - } - - private suspend fun composeHardwareFundingTransaction( - walletId: String, - address: String, - sats: ULong, - satsPerVByte: ULong, - ): HwFundingTransaction = runCatching { - withTimeout(HW_COMPOSE_TIMEOUT) { - hwWalletRepo.composeFundingTransaction( - walletId = walletId, - address = address, - sats = sats, - satsPerVByte = satsPerVByte, - ).getOrThrow() - } - }.getOrElse { - it.rethrowIfCancellation() - throw HardwareFundingError(it) - } - - private suspend fun signHardwareFunding( - walletId: String, - funding: HwFundingTransaction, - ): HwFundingSignedTx { - val firstAttempt = runSuspendCatching { signHardwareFundingOnce(walletId, funding) } - val error = firstAttempt.exceptionOrNull() ?: return firstAttempt.getOrThrow() - if (!error.isHwSessionFailure()) throw error - - ensureHardwareConnected(walletId) - return signHardwareFundingOnce(walletId, funding) - } - - private suspend fun signHardwareFundingOnce( - walletId: String, - funding: HwFundingTransaction, - ): HwFundingSignedTx = runCatching { - withTimeout(HW_SIGN_TIMEOUT) { - hwWalletRepo.signFunding( - walletId = walletId, - funding = funding, - ).getOrThrow() - } - }.getOrElse { - it.rethrowIfCancellation() - if (it is TimeoutCancellationException) { - hwWalletRepo.disconnectStaleSession(walletId) - throw HardwareSigningTimeoutError(it) - } - throw it - } - - private suspend fun broadcastHardwareFunding( - signedTx: HwFundingSignedTx, - ): HwFundingBroadcastResult { - return runCatching { - withTimeout(HW_BROADCAST_TIMEOUT) { - hwWalletRepo.broadcastFunding(signedTx).getOrThrow() - } - }.getOrElse { - it.rethrowIfCancellation() - throw HardwareBroadcastError(it) - } - } - - private suspend fun handleHardwareTransferFailure(e: Throwable, walletId: String) { - if (e.isHwUserCancellation()) { - Logger.info("Hardware transfer cancelled on device for '$walletId'", context = TAG) - return - } - if (generateSequence(e) { it.cause }.any { it is HwPassphraseRequiredError }) { - // The device is open on another identity and only the passphrase reopens this one. - Logger.info("Asking for the passphrase to reopen hardware wallet '$walletId'", context = TAG) - _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } - return - } - if (generateSequence(e) { it.cause }.any { it is HwWalletMismatchError }) { - // The device is connected but holds another wallet, so reconnecting it would not help. - Logger.warn("Rejected hardware device holding another wallet for '$walletId'", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = context.getString(R.string.hardware__wallet_mismatch), - ) - return - } - if (e.isHwDeviceBusy()) { - Logger.warn("Blocked hardware transfer for locked or busy device '$walletId'", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = HwErrorPresenter.userMessage(context, e), - ) - return - } - if (e.isHwFirmwareError()) { - Logger.warn("Received hardware firmware error for '$walletId'", e, context = TAG) - showHardwareReconnectRequiredError() - return - } - when (e) { - is HardwareBroadcastError -> { - Logger.warn("Hardware funding transaction is signed but not confirmed broadcast", e, context = TAG) - showHardwareBroadcastError(e) - } - is HardwareReconnectError -> { - Logger.error("Failed to reconnect hardware device", e, context = TAG) - showHardwareReconnectError(walletId) - } - is HardwareSigningTimeoutError -> { - Logger.warn("Timed out hardware transfer signing for '$walletId'", e, context = TAG) - showHardwareTimeoutError() - } - is HardwareFundingError -> { - Logger.warn("Failed to compose hardware transfer funding for '$walletId'", e, context = TAG) - if (e.isHardwareInteractionTimeout()) { - showHardwareConnectivityError() - } else { - showHardwareFundingError(e) - } - } - else -> { - Logger.error("Hardware transfer failed", e, context = TAG) - ToastEventBus.send(e) - } - } - } - - private suspend fun showHardwareBroadcastError(error: HardwareBroadcastError) { - if (!error.isBroadcastConnectivityFailure()) { - pendingHwFundingBroadcast = null - _spendingUiState.update { it.copy(hasPendingHwBroadcast = false) } - ToastEventBus.send(error.cause ?: error) - return - } - showHardwareConnectivityError() - } - - private suspend fun showHardwareConnectivityError() { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.other__connection_issue), - description = context.getString(R.string.other__connection_issues_explain), - ) - } - - private fun Throwable.isHardwareInteractionTimeout(): Boolean = - this is HardwareFundingError && - generateSequence(this) { it.cause }.any { it is TimeoutCancellationException } - - private suspend fun showHardwareReconnectError(walletId: String) { - if (hwWalletRepo.isKnownBluetoothDevice(walletId)) { - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = context.getString(R.string.hardware__connect_title), - description = context.getString(R.string.hardware__connect_error), - ) - return - } - showHardwareReconnectRequiredError() - } - - private suspend fun showHardwareReconnectRequiredError() { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__transfer_hw__reconnect_error_title), - description = context.getString(R.string.lightning__transfer_hw__reconnect_error_description), - ) - } - - private suspend fun showHardwareTimeoutError() { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = context.getString(R.string.wallet__payment_timeout), - ) - } - - private suspend fun showHardwareFundingError(e: Throwable) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = e.cause?.message ?: e.message ?: context.getString(R.string.common__error_body), - ) - } - - private suspend fun hwFundingFeeReserve(balanceSats: ULong): ULong { - val satsPerVByte = fetchHwFundingSatsPerVByte().getOrNull() - ?: return hwFundingFallbackFeeReserve(balanceSats) - return satsPerVByte.safe() * HW_FUNDING_TX_VBYTES.safe() - } - - private fun hwFundingFallbackFeeReserve(balanceSats: ULong): ULong { - val minReserve = HW_FUNDING_FALLBACK_SATS_PER_VBYTE.safe() * HW_FUNDING_TX_VBYTES.safe() - val fallback = (balanceSats.toDouble() * Defaults.fallbackFeePercent).toULong() - return maxOf(minReserve, fallback) - } - - private suspend fun hwFundingSatsPerVByte(): ULong = - fetchHwFundingSatsPerVByte().getOrDefault(HW_FUNDING_FALLBACK_SATS_PER_VBYTE) - - private suspend fun fetchHwFundingSatsPerVByte(): Result = - lightningRepo.getFeeRateForSpeed(TransactionSpeed.Fast) - - // endregion - - fun updateAdvancedTransferValues(clientBalanceSat: ULong) { - advancedLimitsJob?.cancel() - advancedLimitsJob = viewModelScope.launch { - _spendingUiState.update { it.copy(isLoading = true) } - updateTransferValues(clientBalanceSat) - - val values = _transferValues.value - val budget = currentFundingBudget() - if (values.maxLspBalance == 0uL || budget == null) { - _spendingUiState.update { it.copy(isLoading = false) } - return@launch - } - - val affordableMax = resolveAffordableLspBalance( - clientBalance = clientBalanceSat, - budget = budget, - minLspBalance = values.minLspBalance, - maxLspBalance = values.maxLspBalance, - ) - if (affordableMax != null && affordableMax < values.maxLspBalance) { - Logger.info( - "Settled max capacity '${values.maxLspBalance}' on affordable '$affordableMax'", - context = TAG, - ) - _transferValues.update { it.copy(maxLspBalance = affordableMax) } - } - _spendingUiState.update { it.copy(isLoading = false) } - } - } - - fun updateTransferValues(clientBalanceSat: ULong) { - val options = blocktankRepo.calculateLiquidityOptions(clientBalanceSat).getOrNull() - _transferValues.value = if (options != null) { - TransferValues( - defaultLspBalance = options.defaultLspBalanceSat, - minLspBalance = options.minLspBalanceSat, - maxLspBalance = options.maxLspBalanceSat, - maxClientBalance = options.maxClientBalanceSat, - ) - } else { - TransferValues() - } - } - - // endregion + private val isNodeRunning = lightningRepo.lightningState.map { it.nodeStatus?.isRunning ?: false } + .stateIn(viewModelScope, SharingStarted.Lazily, false) // region Savings @@ -1993,29 +425,14 @@ class TransferViewModel @Inject constructor( // endregion + private suspend fun awaitNodeRunning() { + withTimeoutOrNull(1.minutes) { + isNodeRunning.first { it } + } + } + companion object { private const val TAG = "TransferViewModel" - private const val MIN_STEP_DELAY_MS = 500L - private const val POLL_INTERVAL_MS = 2_500L - private const val MAX_CONSECUTIVE_ERRORS = 5 - - /** Live re-quotes allowed while settling the advertised max transfer on an affordable balance. */ - private const val MAX_AFFORDABILITY_ROUNDS = 2 - - /** Conservative vbyte reserve for multi-input hardware funding before exact compose runs. */ - private const val HW_FUNDING_TX_VBYTES = 1_200uL - - /** Minimum fallback fee rate when fee estimates are temporarily unavailable. */ - private const val HW_FUNDING_FALLBACK_SATS_PER_VBYTE = 3uL - - /** Upper bound for exact hardware funding composition before signing starts. */ - private val HW_COMPOSE_TIMEOUT = 45.seconds - - /** Upper bound for one hardware signing attempt before the UI releases the button. */ - private val HW_SIGN_TIMEOUT = 120.seconds - - /** Upper bound for broadcasting a signed hardware funding transaction. */ - private val HW_BROADCAST_TIMEOUT = 120.seconds /** How long the confirm/progress flow waits for the on-chain claim before backgrounding it. */ private val SWAP_CLAIM_TIMEOUT = 30.seconds @@ -2025,123 +442,9 @@ class TransferViewModel @Inject constructor( /** Minimum sats held back from a swap to cover Lightning routing fees. */ private const val MIN_LN_ROUTING_FEE_RESERVE_SATS = 10L - - const val LN_SETUP_STEP_0 = 0 - const val LN_SETUP_STEP_1 = 1 - const val LN_SETUP_STEP_2 = 2 - const val LN_SETUP_STEP_3 = 3 } } -private class HardwareReconnectError(cause: Throwable) : AppError(cause) -private class HardwareFundingError(cause: Throwable) : AppError(cause) -private class HardwareSigningTimeoutError(cause: Throwable) : AppError(cause) -private class HardwareBroadcastError(cause: Throwable) : AppError(cause) - -private data class PendingHwFundingBroadcast( - val orderId: String, - val walletId: String, - val address: String, - val amountSats: ULong, - val signedTx: HwFundingSignedTx, -) { - fun matches(order: IBtOrder, walletId: String, address: String): Boolean = - orderId == order.id && - this.walletId == walletId && - this.address == address && - amountSats == order.feeSat -} - -private val IBtOrder.fundingAddress: String - get() = payment?.onchain?.address.orEmpty() - -data class TransferToSpendingUiState( - val clientBalanceSat: ULong = 0uL, - val lspBalanceSat: ULong = 0uL, - val feeSat: ULong = 0uL, - val order: IBtOrder? = null, - val isAdvanced: Boolean = false, - val maxAllowedToSend: Long = 0, - val balanceAfterFee: Long = 0, - val quarterAmount: Long = 0, - val isLoading: Boolean = false, - val isSigning: Boolean = false, - val isConnectingDevice: Boolean = false, - val hasPendingHwBroadcast: Boolean = false, - val isHwPassphraseRequired: Boolean = false, - val isVerifyingHwPassphrase: Boolean = false, - val hwMiningFeeSats: ULong = 0uL, - val miningFeeSats: ULong = 0uL, - val isConfirmFeeReady: Boolean = false, - val isConfirmPaying: Boolean = false, - val shouldUseSendAll: Boolean = false, - val spendableBalance: ULong = 0uL, - val receivingAmount: Long = 0, - val feeEstimate: Long? = null, - val fundingBudgetSats: ULong? = null, - val hwFundingWalletId: String? = null, -) { - val isBusy: Boolean get() = isConfirmPaying || isSigning - - /** - * Whether the hardware sign screen may be left. Connecting or unlocking can be abandoned, and leaving - * cancels it; once the device is asked to sign, or a broadcast is on its way, it cannot. - */ - val canLeave: Boolean get() = !isBusy || isConnectingDevice - - val confirmLeavingAmountSats: ULong - get() = if (shouldUseSendAll) { - spendableBalance - } else { - feeSat.safe() + miningFeeSats.safe() - } -} - -private fun TransferToSpendingUiState.withFundingPlan( - plan: SpendingConfirmFundingPlan, - feeSat: ULong = this.feeSat, -) = copy( - feeSat = feeSat, - isConfirmFeeReady = true, - miningFeeSats = plan.miningFeeSats, - shouldUseSendAll = plan.shouldUseSendAll, - spendableBalance = plan.spendableBalance, -) - -private data class SpendingFundingTarget( - val feeSat: ULong, - val address: String, - val orderId: String?, -) - -private data class SpendingConfirmFundingPlan( - val orderId: String?, - val feeRates: FeeRates, - val miningFeeSats: ULong, - val shouldUseSendAll: Boolean, - val selectedUtxos: List?, - val spendableBalance: ULong, - val totalOnchainBalance: ULong, - val maxSendable: ULong, -) - -data class TransferValues( - val defaultLspBalance: ULong = 0u, - val minLspBalance: ULong = 0u, - val maxLspBalance: ULong = 0u, - val maxClientBalance: ULong = 0u, -) - -sealed interface TransferEffect { - data object OnQuoteReady : TransferEffect - data object OnSpendingFundingPaid : TransferEffect - data class OnFundingPending(val attempt: to.bitkit.repositories.OnchainSendAttempt) : TransferEffect - data object OnHwTxSigned : TransferEffect - data object OnTransferUnavailable : TransferEffect - data class ToastException(val e: Throwable) : TransferEffect - data class ToastError(val title: String, val description: String) : TransferEffect -} - /** Whether a transfer to savings swaps funds out or closes a channel (default). */ enum class SavingsTransferMode { SWAP, CLOSE } @@ -2174,7 +477,3 @@ sealed interface SavingsSwapResult { data class Failure(val reason: String) : SavingsSwapResult } // endregion - -private fun Throwable.rethrowIfCancellation() { - if (this is CancellationException && this !is TimeoutCancellationException) throw this -} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 240b4926fc..9b9bf5a16a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -36,7 +36,6 @@ متابعة تم النسخ إلى الحافظة نسخ - افتراضي حذف نعم، احذف لا، إلغاء @@ -47,7 +46,6 @@ لاحقًا اعرف المزيد الحد الأقصى - الحد الأدنى أبدًا لا حسنًا @@ -159,28 +157,18 @@ تحويل\n<accent>إجباري</accent> تحويل من المدخرات استخدم محفظة أخرى + تحويل من محفظة الأجهزة رصيد الإنفاق - أسهل خيار هو استخدام مدخرات Bitkit الخاصة بك أو إرسال بيتكوين من محفظة مختلفة. موّل <accent>رصيد الإنفاق</accent> - رمز QR لقناة LNURL إعداد يدوي - رصيد الإنفاق - امسح رمز QR للمطالبة بقناة LNURL من LSP آخر، أو اختر الإعداد اليدوي. - إعداد <accent>متقدم</accent> السعة الواردة الحد الأقصى للـ HTLC الوارد الحد الأدنى للـ HTLC الوارد قابل للاستخدام أرصدة Lightning - سيولة رصيد الإنفاق - يستخدم رصيد الإنفاق شبكة Lightning لجعل مدفوعاتك أرخص وأسرع وأكثر خصوصية.\n\nهذا يعمل مثل الوصول إلى الإنترنت، لكنك تدفع مقابل السيولة والتوجيه بدلاً من عرض النطاق الترددي.\n\nيتضمن هذا الإعداد بعض التكاليف لمرة واحدة. - السيولة\n<accent>والتوجيه</accent> ارتفاع أرشفة مراقب القناة حد HTLC الصادر التالي الحد الأدنى لـ HTLC الصادر التالي - قبل أن تتمكن من تحويل الأموال من رصيد مدخراتك، تحتاج إلى إرسال Bitcoin إلى محفظة Bitkit الخاصة بك. - تمويل المحفظة - لا توجد أموال متاحة معرّف عقدة LDK عقدة Lightning وقت مزامنة محفظة Lightning @@ -219,29 +207,15 @@ يرجى الانتظار، جارٍ تحويل أموالك. يجب أن يستغرق هذا <accent>±10 ثوانٍ.</accent> الأموال\n<accent>قيد التحويل</accent> وقت مزامنة درجات تحديد المسار - متابعة استخدام Bitkit - جارٍ معالجة الدفع - الدفع ناجح - في قائمة انتظار الفتح - جارٍ فتح الاتصال - يرجى الانتظار، جارٍ تحويل أموالك. يجب أن يستغرق هذا <accent>±10 دقائق.</accent> قابل للإنفاق على السلسلة الإنفاق - رسوم السيولة - سعة\n<accent>الاستقبال</accent> المبلغ الذي يمكنك تحويله إلى رصيد الإنفاق محدود حاليًا بـ ₿ {amount}. - تحويلك إلى رصيد الإنفاق محدود بسبب سياسة السيولة. للتفاصيل، زُر مركز المساعدة. الحد الأقصى لرصيد الإنفاق 25% - تحويل\n<accent>إلى الإنفاق</accent> إلى الإنفاق - استخدم الافتراضيات رسوم الخدمة رسوم الشبكة الإجمالي - ابدأ - حوّل الأموال إلى رصيد الإنفاق للاستمتاع بمعاملات فورية ورخيصة مع الأصدقاء والعائلة والتجار. - تحويل\n<accent>إلى الإنفاق</accent> رصيد الإنفاق الحالة الدعم @@ -254,14 +228,9 @@ تحويل الأموال اسحب للتحويل جارٍ التحويل - ابدأ - موّل رصيد الإنفاق للاستمتاع بمعاملات فورية ورخيصة مع الأصدقاء والعائلة والتجار. - رصيد\n<accent>الإنفاق</accent> نجح التحويل تم تحويل أموالك. يمكنك استخدام هذه الأموال فورًا. - تم تحويل أموالك ورصيد الإنفاق جاهز للاستخدام. تم نقل الأموال\n<accent>إلى المدخرات</accent> - تم نقل الأموال\n<accent>إلى الإنفاق</accent> يرجى الانتظار حتى يتصل Bitkit بشبكة الدفع (±10 ثوانٍ). أرصدة المحفظة ابدأ الآن @@ -309,7 +278,6 @@ الحرية في\n<accent>جيبك</accent> يمنحك Bitkit مفاتيح إدارة أموالك. أنفق الآن أو ادخر للمستقبل. الخيار لك. مدفوعات\n<accent>فورية</accent> - *لا يوفر Bitkit حاليًا خدمات Lightning في بلدك، لكن يمكنك الاتصال بعقد أخرى. أنفق Bitcoin أسرع من أي وقت مضى. استمتع بمدفوعات فورية ورخيصة مع الأصدقاء والعائلة والتجار. الخصوصية\n<accent>ليست جريمة</accent> اسحب لإخفاء رصيدك، واستمتع بمدفوعات أكثر خصوصية، واحمِ محفظتك بتفعيل ميزات الأمان. @@ -334,14 +302,6 @@ تمت استعادة اتصال الإنترنت مشاكل في الاتصال بالإنترنت يبدو أنك غير متصل، جارٍ محاولة إعادة الاتصال... - جارٍ المطالبة برمز هدية Bitkit... - جارٍ المطالبة بالهدية - تعذر على Bitkit المطالبة بالأموال. يرجى المحاولة لاحقًا أو التواصل معنا. - خطأ في رمز الهدية - تم استخدام رمز هدية Bitkit هذا بالفعل، وتم صرف الأموال. - رمز مستخدم - عذرًا، لقد تأخرت! تمت المطالبة بجميع الهدايا لهذا الرمز. - نفدت الهدايا اعرف المزيد مفهوم رصيد مرتفع @@ -584,7 +544,6 @@ الإشعارات إيقاف تشغيل - الإعداد في الخلفية السماح بالإشعارات الإشعارات يجد تطابقات دقيقة للمبلغ لتقليل الصرف @@ -951,7 +910,6 @@ خفّض الرسوم المخصصة وحاول مرة أخرى. الرسوم أقل من الحد الأدنى زِد الرسوم المخصصة وحاول مرة أخرى. - الحد الأدنى ملاحظة تم استلام Bitcoin انقطع اتصال النظير. @@ -959,18 +917,8 @@ استلام أموال Lightning استلام Bitcoin فاتورة Bitcoin - لاستلام المزيد من Bitcoin الفوري، يحتاج Bitkit لزيادة السيولة. سيتم خصم رسوم شبكة <accent>{networkFee}</accent> ورسوم مزود الخدمة <accent>{serviceFee}</accent> من المبلغ المحدد. - لإعداد رصيد الإنفاق، سيتم خصم رسوم شبكة <accent>{networkFee}</accent> ورسوم مزود الخدمة <accent>{serviceFee}</accent>. تم نسخ الفاتورة إلى الحافظة فاتورة Lightning - فعّل الإعداد في الخلفية للخروج من Bitkit بأمان أثناء تكوين رصيدك. - الإعداد في الخلفية - سيولة رصيد الإنفاق - سيولة إضافية لرصيد الإنفاق - إعداد رصيد الإنفاق - زيادة سعة الاستقبال - يستخدم رصيد الإنفاق شبكة Lightning لجعل مدفوعاتك أرخص وأسرع وأكثر خصوصية.\n\nهذا يعمل مثل الوصول إلى الإنترنت، لكنك تدفع مقابل السيولة والتوجيه بدلاً من عرض النطاق الترددي.\n\nيتضمن هذا الإعداد بعض التكاليف لمرة واحدة. - يستخدم رصيد الإنفاق شبكة Lightning لجعل مدفوعاتك أرخص وأسرع وأكثر خصوصية.\n\nهذا يعمل مثل الوصول إلى الإنترنت، لكنك تدفع مقابل السيولة والتوجيه بدلاً من عرض النطاق الترددي.\n\nيحتاج Bitkit لزيادة سعة الاستقبال لرصيد الإنفاق لمعالجة هذا الدفع. ملاحظة اختيارية للدافع استمتع بمعاملات فورية ورخيصة\nمع الأصدقاء والعائلة\nوالتجار. استلم على <accent>رصيد الإنفاق</accent> @@ -980,7 +928,6 @@ تلقائي المدخرات الإنفاق - ستستلم جهة الاتصال لصق الفاتورة إدخال يدوي @@ -1250,7 +1197,6 @@ لم يتم العثور على القناة تم تحويل الأموال في رصيد إنفاقك إلى مدخراتك. تم إغلاق الاتصال - تحويل من محفظة الأجهزة إلى المدخرات المبلغ منخفض جدًا للتحويل إلى المدخرات. إغلاق القناة بدلاً من ذلك @@ -1259,21 +1205,9 @@ رسوم الخدمة بدأ التحويل وهو <accent>قيد التسوية على السلسلة</accent>. سيتم تحديث رصيد مدخراتك تلقائيًا عند اكتماله. التحويل\n<accent>في الطريق</accent> - لا تكفي مدخراتك لتغطية رسوم السيولة لسعة الاستلام هذه. اختر مبلغًا أصغر. - الأموال غير كافية - سعة الاستلام محدودة حاليًا بـ ₿ {amount}. - الحد الأقصى لسعة الاستلام - لا تكفي مدخراتك لتغطية هذا التحويل ورسومه. جرّب مبلغًا أصغر. - المدخرات غير كافية - الرسوم أعلى بمقدار {amount} لأن معدلات الرسوم أو رصيد مدخراتك تغيّر أثناء وجودك في هذه الشاشة. اسحب مرة أخرى للتأكيد. - الرسوم أعلى بمقدار {amount} الآن بعد إنشاء طلبك. اسحب مرة أخرى للتأكيد. - تغيّرت الرسوم <accent>جارٍ الاتصال</accent>\nبالشبكة - فتح Trezor Connect يرجى إعادة توصيل جهازك، يبدو أنه غير متصل بهاتفك. إعادة توصيل الجهاز - وقّع باستخدام\n<accent>جهازك</accent> - تم توقيع\n<accent>المعاملة</accent> التحويل جاهز خلال %s التحويل غير متاح لم تعد التحويلات التلقائية إلى رصيد الإنفاق متاحة. استخدم عقدتك الخاصة، أو أنشئ اتصالًا مع مزوّد آخر. @@ -1657,12 +1591,6 @@ هذا الأسبوع هذه السنة اليوم - عرض السيولة لم يعد صالحًا. حاول مرة أخرى للحصول على عرض جديد. - العرض غير متاح - الحد الأقصى الذي يمكنك استلامه في رصيد الإنفاق الآن هو ₿ {amount}. - الحد الأقصى لسعة الاستلام - سعة الإنفاق الإضافية غير متاحة حاليًا. حاول لاحقًا أو تواصل مع الدعم إذا استمر حدوث ذلك. - سعة الإنفاق غير متاحة تعذر على 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 967cda1ff1..a0867040b6 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -36,7 +36,6 @@ Continuar Copiado Copiar - Default Borrar Sí, Borrar No, Cancelar @@ -47,7 +46,6 @@ Después Más info. Max - Min Nunca No OK @@ -166,28 +164,18 @@ Forzar\n<accent>Transferencia</accent> Transferir desde Ahorros Usar Otra Billetera + Transferir desde billetera HW Saldo de gastos - La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde una billetera diferente. Fondee su <accent>saldo de gastos</accent> - QR de canal LNURL Configuración Manual - Saldo de gastos - Escanea un código QR para obtener tu canal LNURL de otro LSP, o selecciona la configuración manual. - Avanzado <accent>configuracion</accent> Capacidad entrante Máximo HTLC entrante Mínimo HTLC entrante Es utilizable Saldos Lightning - Liquidez del saldo para gasto - Tu saldo para gastar usa la Red Lightning para que tus pagos sean más baratos, rápidos y privados.\n⠀\nFunciona como el acceso a internet, pero pagas por liquidez y ruteo en lugar de por ancho de banda.\n⠀\nEsta configuración incluye algunos costos únicos. - Liquidez\n<accent>y ruteo</accent> Altura de archivo del monitor de canal Límite del próximo HTLC saliente Mínimo del próximo HTLC saliente - Para transferir fondos a su saldo de ahorros, primero tiene que enviar bitcoin a su billetera Bitkit. - Financiar billetera - Sin fondos disponibles ID del nodo LDK Nodo Lightning Hora de sincronización de billetera Lightning @@ -226,30 +214,15 @@ Espera un momento, tu transferencia está en proceso. Tomará aproximadamente <accent>10 segundos.</accent> Fondos\n<accent>en proceso</accent> Hora de sincronización de puntuaciones de ruta - Continuar Usando Bitkit - Procesando pago - Pago Efectuado - En Fila Para la Apertura - Abriendo Conexión - Por favor, espere, su transferencia está en curso. Esto debería tardar <accent>±10 minutos.</accent> Gastable onchain Gasto - Comisión por liquidez - Capacidad de\n<accent>Recibir</accent> - Ahorros insuficientes El monto que puedes transferir a tu saldo para gastar está limitado a ₿ {amount}. - Tu transferencia al saldo para gastos está limitada por la política de liquidez. Visita el Centro de ayuda para más detalles. Saldo máximo de gastos 25% - Transferir\n<accent>a gastos</accent> Hacia Gastos - Usar default Comisión de servicio Comisiones de red Total - Empezar - Transfiera fondos a su saldo de gastos para disfrutar de transacciones baratas e instantáneas con amigos y comercios. - Transferir\n<accent>a gastos</accent> Saldo para gastos Estatus Soporte @@ -262,14 +235,9 @@ Transferir fondos Desliza para enviar TRANSFERENCIA EN PROGRESO - Empezar - Fondee el saldo de gastos para disfrutar de transacciones baratas e instantáneas con amigos y comercios. - Saldo de\n<accent>Gasto</accent> Transferencia exitosa Sus fondos han sido transferidos. Podrá utilizar estos fondos inmediatamente. - Tus fondos han sido transferidos y tu saldo para gastar está listo para usarse. Fondos movidos\n<accent>a gastos</accent> - Fondos movidos\n<accent>a gastos</accent> Por favor, espere a que Bitkit se conecte a la red de pagos (±10 seg.). Saldos de billetera Comenzar @@ -317,7 +285,6 @@ Libertad en\n<accent>tu bolsillo</accent> Bitkit te da las llaves para manejar tu dinero. Gasta ahora o ahorra para después. Tú decides. Pagos\n<accent>al instante</accent> - Bitkit no tiene soporte Lightning en tu región, pero puedes usar nodos externos manualmente. Gasta bitcoin más rápido que nunca. Disfruta pagos instantáneos y baratos con amigos, familia y comercios. La privacidad\n<accent>no es un delito</accent> Tu dinero, tu privacidad. Desliza para ocultar tu saldo y gasta de forma más privada, sin rastreo de datos y sin KYC. @@ -342,14 +309,6 @@ Conexión a Internet restablecida Problemas de conectividad a Internet Parece que está desconectado, intentando reconectarse... - Canjeando su código de regalo Bitkit... - Canjeando regalo - Bitkit no pudo recibir los fondos. Por favor, intente de nuevo más tarde o póngase en contacto con nosotros. - Error de código de regalo - Este código de regalo de Bitkit ya ha sido utilizado y los fondos han sido pagados. - Código usado - Lo sentimos, ¡llegaste tarde! Todos los regalos para este código ya han sido canjeados. - Sin regalos Más información Entendido Saldo Elevado @@ -606,7 +565,6 @@ Notificaciones Apagado Encendido - Configurar en segundo plano Permitir notificaciones Notificaciones Encuentra coincidencias exactas de monto para minimizar el cambio @@ -982,7 +940,6 @@ Reduzca la tasa manual e inténtelo de nuevo. Tasa por debajo del límite mínimo Aumente la tasa e inténtelo de nuevo. - MÍNIMO Nota Bitcoin recibido Par desconectado. @@ -990,18 +947,8 @@ Recibir fondos Lightning Recibir Bitcoin Factura Bitcoin - Para recibir más Bitcoin al instante, Bitkit necesita aumentar tu liquidez. Se descontará una comisión de red de <accent>{networkFee}</accent> y una comisión del proveedor de servicio de <accent>{serviceFee}</accent> del monto que especificaste. - Para configurar tu saldo para gastar, se descontará una comisión de red de <accent>{networkFee}</accent> y una comisión del proveedor de servicio de <accent>{serviceFee}.</accent> Factura copiada en el portapapeles Factura Lightning - Activa la configuración en segundo plano para poder cerrar Bitkit mientras se configura tu saldo. - Configurar en segundo plano - Liquidez del saldo para gastar - Liquidez adicional del saldo para gastar - Configuración del saldo de gastos - Aumentar la capacidad de recepción - Tu saldo para gastar usa la red Lightning para que tus pagos sean más baratos, rápidos y privados.\n⠀\nFunciona como el acceso a internet, pero pagas por liquidez y ruteo en lugar de por ancho de banda.\n⠀\nEsta configuración incluye algunos costos únicos. - Su saldo de gastos utiliza la Lightning Network para que sus pagos sean más baratos, más rápidos y más privados.\n\nFunciona como el acceso a Internet, pero usted paga por la liquidez y el enrutamiento en lugar del ancho de banda.\n\nBitkit necesita aumentar la capacidad de recepción de su saldo de gastos para procesar este pago. Nota opcional para el pagador Disfruta transacciones instantáneas\ny baratas con amigos, familia\ny comercios. Recibe en <accent>saldo de gastos</accent> @@ -1011,7 +958,6 @@ Auto Ahorros Gastos - Recibirás Contacto Pegar factura Introducir manualmente @@ -1281,7 +1227,6 @@ Falló la verificación de la dirección. Revise la dirección en su dispositivo y vuelva a intentarlo. Este dispositivo contiene una billetera distinta a la que vinculó. Conecte la billetera vinculada o añada esta como una nueva billetera de hardware. Canal no encontrado - Transferir desde billetera HW A ahorros El monto es demasiado bajo para transferirlo a ahorros. Cerrar el canal en su lugar @@ -1290,20 +1235,9 @@ Comisión de servicio Su transferencia se inició y se está <accent>liquidando onchain</accent>. Su saldo de ahorros se actualizará automáticamente cuando termine. Transferencia\n<accent>en camino</accent> - Sus ahorros no alcanzan para cubrir la comisión de liquidez de esta capacidad de recepción. Elija un monto menor. - Fondos insuficientes - La capacidad de recepción está limitada actualmente a ₿ {amount}. - Capacidad de recepción máxima - Sus ahorros no alcanzan para cubrir esta transferencia y sus comisiones. Intente con un monto menor. - Las comisiones son {amount} más altas porque las tarifas o su saldo de ahorros cambiaron mientras estaba en esta pantalla. Deslice de nuevo para confirmar. - Las comisiones son {amount} más altas ahora que se creó su pedido. Deslice de nuevo para confirmar. - Las comisiones cambiaron <accent>Conectando</accent>\na la red - Abrir Trezor Connect Vuelva a conectar su dispositivo de hardware; parece que está desconectado de su teléfono. Reconectar dispositivo de hardware - Firme con\n<accent>su dispositivo</accent> - Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s Transferencia no disponible Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. @@ -1661,12 +1595,6 @@ Esta semana Este año Hoy - La cotización de liquidez ya no es válida. Vuelva a intentarlo para obtener una nueva. - Cotización no disponible - Lo máximo que puede recibir en su saldo de gastos en este momento es ₿ {amount}. - Capacidad de recepción máxima - La capacidad adicional de gastos no está disponible en este momento. Inténtelo más tarde o contacte a soporte si el problema continúa. - Capacidad adicional no disponible Bitkit no pudo cargar sus monedas. Inténtelo de nuevo. El monto supera su saldo disponible. Saldo insuficiente diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index e06dddfcc2..99456e9a70 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -36,7 +36,6 @@ Continua Copiat al porta-retalls Còpia - Predeterminat Suprimir Sí, Elimina No, cancela @@ -47,7 +46,6 @@ Després Aprèn més Màxim - Mínim Mai No OK @@ -163,28 +161,18 @@ Forçar\n<accent>transferència</accent> Transferir des d\'estalvis Utilitza un altre moneder + Transferir des del moneder HW Balanç de despesa - La forma més fàcil és utilitzar els teus estalvis de Bitkit o enviar bitcoin des d\'una altra cartera. Finança el teu <accent>saldo de despesa</accent> - QR de canal LNURL Configuració manual - Balanç de despesa - Escaneja un QR per reclamar el teu canal LNURL d\'un altre LSP, o tria la configuració manual. - Configuració <accent>avançada</accent> Capacitat d\'entrada HTLC d\'entrada màxim HTLC d\'entrada mínim És utilitzable Saldos Lightning - Liquiditat del saldo de despesa - El teu saldo de despesa utilitza Lightning Network per fer els teus pagaments més barats, ràpids i privats.\n\nAixò funciona com l\'accés a Internet, però pagues per liquiditat i encaminament en lloc d\'amplada de banda.\n\nAquesta configuració inclou alguns costos únics. - Liquiditat\n<accent>i encaminament</accent> Alçada d\'arxiu del monitor de canal Límit del pròxim HTLC de sortida Mínim del pròxim HTLC de sortida - Abans de poder transferir fons del teu saldo d\'estalvis, has d\'enviar bitcoin a la teva cartera Bitkit. - Finançar cartera - No hi ha fons disponibles ID de Node LDK Node de Lightning Temps de sincronització de la cartera Lightning @@ -223,29 +211,15 @@ Si us plau, espera, la transferència de fons està en curs. Hauria de trigar <accent>±10 segons.</accent> Fons\n<accent>en transferència</accent> Temps de sincronització de puntuacions de rutes - Continua utilitzant Bitkit - Processant pagament - Pagament realitzat amb èxit - En cua per obrir - Obertura de connexió - Si us plau, espera, la transferència de fons està en curs. Hauria de trigar <accent>±10 minuts.</accent> Gastable Onchain Despesa - Tarifa de liquiditat - Capacitat de\n<accent>recepció</accent> L\'import que pots transferir al teu saldo de despesa està limitat a ₿ {amount}. - La teva transferència al saldo de despesa està limitada per la política de liquiditat. Per a més detalls, visita el Centre d\'ajuda. Màxim del saldo de despesa 25% - Transferir\n<accent>a despesa</accent> A despesa - Usar predeterminats Tarifes de servei Tarifes de xarxa Total - Començar - Transfereix fons al teu saldo de despesa per gaudir de transaccions instantànies i barates amb amics, família i comerciants. - Transferir\n<accent>a despesa</accent> Saldo de despesa Estat Suport @@ -258,14 +232,9 @@ Transferir fons Desplaçar per Transferir TRANSFERÈNCIA EN CURS - Començar - Finança el teu saldo de despesa per gaudir de transaccions instantànies i barates amb amics, família i comerciants. - Saldo de\n<accent>despesa</accent> Transferència realitzada Els teus fons han estat transferits. Pots utilitzar aquests fons immediatament. - Els teus fons han estat transferits i el teu saldo de despesa està llest per usar. Fons moguts\n<accent>a estalvis</accent> - Fons moguts\n<accent>a despesa</accent> Si us plau, espereu que Bitkit es connecti a la xarxa de pagament (±10 segons). Saldos de la cartera Comença @@ -313,7 +282,6 @@ Llibertat a\n<accent>la teva butxaca</accent> Bitkit et dona les claus per gestionar els teus diners. Gasta ara o estalvia per després. La decisió és teva. Pagaments\n<accent>instantanis</accent> - *Bitkit no ofereix serveis Lightning al teu país, però encara pots connectar-te a altres nodes. Gasta bitcoin més ràpid que mai. Gaudeix de pagaments instantanis i barats amb amics, família i comerciants. La privacitat\n<accent>no és un crim</accent> Llisca per amagar el teu saldo, gaudeix de pagaments més privats i protegeix la teva cartera habilitant funcions de seguretat. @@ -338,14 +306,6 @@ Connexió a Internet restaurada Problemes de connexió a Internet Sembla que estàs desconnectat, intentant tornar a connectar-te... - Reclamant el teu codi de regal de Bitkit... - Reclamant regal - Bitkit no ha pogut reclamar els fons. Si us plau, torna-ho a provar més tard o contacta\'ns. - Error de codi de regal - Aquest codi de regal de Bitkit ja ha estat utilitzat i els fons han estat pagats. - Codi utilitzat - Ho sentim, arribes tard! Tots els regals per a aquest codi ja han estat reclamats. - Sense regals Aprèn més Entès Saldo alt @@ -599,7 +559,6 @@ Notificacions Desactivat Activat - Configurar en segon pla Permet les notificacions Notificacions Troba coincidències d\'import exactes per minimitzar el canvi @@ -975,7 +934,6 @@ Redueix la tarifa personalitzada i torna-ho a provar. Tarifa per sota del límit mínim Augmenta la tarifa personalitzada i torna-ho a provar. - MÍNIM Nota Rebut Bitcoin Parell desconnectat. @@ -983,18 +941,8 @@ Rebre fons Lightning Rebre Bitcoin Factura de Bitcoin - Per rebre més Bitcoin instantani, Bitkit ha d\'augmentar la teva liquiditat. Es descomptarà una tarifa de xarxa de <accent>{networkFee}</accent> i una tarifa de proveïdor de servei de <accent>{serviceFee}</accent> de l\'import especificat. - Per configurar el teu saldo de despesa, es descomptarà una tarifa de xarxa de <accent>{networkFee}</accent> i una tarifa de proveïdor de servei de <accent>{serviceFee}</accent>. Factura copiada al porta-retalls Factura Lightning - Habilita la configuració en segon pla per sortir de Bitkit de manera segura mentre es configura el teu saldo. - Configurar en segon pla - Liquiditat del saldo de despesa - Liquiditat addicional del saldo de despesa - Configuració del saldo de despesa - Augmentar capacitat de recepció - El teu saldo de despesa utilitza Lightning Network per fer els teus pagaments més barats, ràpids i privats.\n\nAixò funciona com l\'accés a Internet, però pagues per liquiditat i encaminament en lloc d\'amplada de banda.\n\nAquesta configuració inclou alguns costos únics. - El teu saldo de despesa utilitza Lightning Network per fer els teus pagaments més barats, ràpids i privats.\n\nAixò funciona com l\'accés a Internet, però pagues per liquiditat i encaminament en lloc d\'amplada de banda.\n\nBitkit necessita augmentar la capacitat de recepció del teu saldo de despesa per processar aquest pagament. Nota opcional per al pagador Gaudeix de transaccions instantànies i barates\namb amics, família\ni comerciants. Rebre al <accent>saldo de despesa</accent> @@ -1004,7 +952,6 @@ Auto Estalvis Despesa - Rebràs Contacte Enganxa factura Introduïu manualment @@ -1274,7 +1221,6 @@ Aquest dispositiu conté una cartera diferent de la que has vinculat. Connecta la cartera vinculada o afegeix aquesta com a nova cartera de maquinari. Canal no trobat Els fons del teu saldo de despesa s\'han transferit als teus estalvis. - Transferir des del moneder HW A estalvis L\'import és massa baix per transferir-lo a estalvis. Tancar el canal @@ -1283,21 +1229,9 @@ Tarifa de servei La teva transferència s\'ha iniciat i <accent>s\'està liquidant onchain</accent>. El teu saldo d\'estalvis s\'actualitzarà automàticament quan es completi. Transferència\n<accent>en camí</accent> - Els teus estalvis no poden cobrir la tarifa de liquiditat per a aquesta capacitat de recepció. Tria un import més petit. - Fons insuficients - La capacitat de recepció està limitada actualment a ₿ {amount}. - Capacitat de recepció màxima - Els teus estalvis no poden cobrir aquesta transferència i les seves tarifes. Prova un import més petit. - Estalvis insuficients - Les tarifes són {amount} més altes perquè les taxes de tarifa o el teu saldo d\'estalvis han canviat mentre eres en aquesta pantalla. Torna a desplaçar per confirmar. - Les tarifes són {amount} més altes ara que s\'ha creat la teva comanda. Torna a desplaçar per confirmar. - Les tarifes han canviat <accent>Connectant</accent>\na la xarxa - Obrir Trezor Connect Torna a connectar el teu dispositiu de maquinari; sembla que s\'ha desconnectat del telèfon. Torna a connectar el dispositiu - Signa amb\n<accent>el teu dispositiu</accent> - Transacció\n<accent>signada</accent> TRANSFERÈNCIA LLESTA EN %s Transferència no disponible Les transferències automàtiques al teu saldo de despesa ja no estan disponibles. Fes servir el teu propi node o configura una connexió amb un altre proveïdor. @@ -1658,12 +1592,6 @@ Aquesta setmana Aquest any Avui - La cotització de liquiditat ja no és vàlida. Torna-ho a provar per obtenir-ne una de nova. - Cotització no disponible - El màxim que pots rebre al teu saldo de despesa ara mateix és ₿ {amount}. - Capacitat de recepció màxima - Ara mateix no hi ha capacitat de despesa addicional disponible. Torna-ho a provar més tard o contacta amb el suport si continua passant. - Capacitat de despesa no disponible Bitkit no ha pogut carregar les teves monedes. Torna-ho a provar. L\'import supera el teu saldo disponible. Saldo insuficient diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 480fb7af5a..da8e1ed581 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -36,7 +36,6 @@ Pokračovat Zkopírováno do schránky Kopírovat - Výchozí Smazat Ano, smazat Ne, zrušit @@ -47,7 +46,6 @@ Později Další informace Max - Min Nikdy Ne OK @@ -166,28 +164,18 @@ Vynutit\n<accent>Převod</accent> Převod z úspor Použijte jinou peněženku + Převod z HW peněženky Dostupný zůstatek - Nejjednodušší možností je použít úspory z Bitkitu nebo poslat bitcoin z jiné peněženky. Navyšte svůj <accent>disponibilní zůstatek</accent> - LNURL kanál QR Manuální nastavení - Dostupný zůstatek - Naskenujte QR kód pro nárokování svého LNURL kanálu od jiného LSP nebo si vyberte ruční nastavení. - Pokročilé <accent>nastavení</accent> Příchozí kapacita Max příchozí HTLC Min příchozí HTLC Je použitelný? Lightning zůstatky - Likvidita disponibilního zůstatku - Váš zůstatek na účtu využívá síť lightning network, aby byly vaše platby levnější, rychlejší a soukromější.\n\nFunguje to jako přístup k internetu, ale místo za šířku pásma platíte za likviditu a směrování.\n\nToto nastavení zahrnuje některé jednorázové náklady. - Likvidita\n<accent>& routing</accent> Archivní výška monitoru kanálu Limit dalšího odchozího HTLC Min dalšího odchozího HTLC - Než budete moci převést prostředky ze svého zůstatku na spořicím účtu, musíte poslat bitcoiny do peněženky Bitkit. - Financovat peněženku - Žádné dostupné prostředky ID uzlu LDK Lightning uzel Čas synchronizace Lightning peněženky @@ -226,29 +214,15 @@ Počkejte prosím, probíhá převod finančních prostředků. To by mělo trvat <accent>±10 sekund.</accent> Finanční prostředky\n<accent>v převodu</accent> Čas synchronizace skóre hledání cest - Pokračujte v používání Bitkit - Zpracování platby - Platba úspěšně proběhla - Ve frontě na otevření - Otevírání spojení - Počkejte prosím, probíhá převod finančních prostředků. To by mělo trvat <accent>±10 minut.</accent> Utratitelné onchain Útraty - Poplatek za likviditu - Příjímací\n<accent>kapacita</accent> Částka, kterou můžete převést na svůj disponibilní zůstatek, je v současné době omezena na ₿ {amount}. - Váš převod do disponibilního zůstatku je omezen z důvodu pravidel likvidity. Podrobnosti naleznete v Centru nápovědy. Maximální disponibilní zůstatek 25% - Převést\n<accent>do výdajů</accent> Do výdajů - Použití výchozích nastavení Poplatky za služby Síťové poplatky Celkem - Začít - Převeďte peníze na svůj disponibilní zůstatek a využívejte okamžité a levné transakce s přáteli, rodinou a obchodníky. - Převést\n<accent>do výdajů</accent> Disponibilní zůstatek Status Podpora @@ -261,14 +235,9 @@ Převést prostředky Přejeďte prstem k provedení převodu PŘEVOD PROBÍHÁ - Začít - Navyšte svůj disponibilní zůstatek a užívejte si okamžité a levné transakce s přáteli, rodinou a obchodníky. - Disponibilní\n<accent>Zůstatek</accent> Přenos úspěšný Vaše prostředky byly převedeny. Tyto prostředky můžete okamžitě použít. - Vaše prostředky byly převedeny a disponibilní zůstatek je připraven k použití. Prostředky převedeny\n<accent>do úspor</accent> - Prostředky převedeny\n<accent>do výdajů</accent> Prosím vyčkejte, až se Bitkit připojí k platební síti (±10 sekund). Zůstatky peněženky Začít @@ -316,7 +285,6 @@ Svoboda ve\n<accent>vaší kapse</accent> Bitkit vám předá klíče ke správě vašich peněz. Utrácejte teď nebo si spořte na později. Volba je na vás. Okamžité\n<accent>platby</accent> - *Bitkit v současné době neposkytuje služby lightning ve vaší zemi, ale stále se můžete připojit k jiným uzlům. Utrácejte bitcoiny rychleji než kdykoli předtím. Užívejte si okamžité a levné platby s přáteli, rodinou a obchodníky. Soukromí\n<accent>není zločin</accent> Přejetím prstem můžete skrýt zůstatek, využívat soukromější platby a chránit peněženku zapnutím bezpečnostních funkcí. @@ -341,14 +309,6 @@ Obnoveno internetové připojení Problémy s připojením k internetu Zdá se, že jste odpojen, snažíte se znovu připojit... - Uplatnění dárkového kódu Bitkit... - Uplatnění daru - Bitkit nemohl nárokovat prostředky. Zkuste to prosím znovu později nebo nás kontaktujte. - Chyba dárkového kódu - Tento dárkový kód BitKitu již byl použit a prostředky byly vyplaceny. - Použitý kód - Omlouváme se, přicházíte pozdě! Všechny dary pro tento kód již byly vyzvednuty. - Dary vyčerpány Další informace Rozumím Vysoký zůstatek @@ -604,7 +564,6 @@ Oznámení Vypnuto Zapnuto - Nastavit na pozadí Povolit oznámení Oznámení Najde přesné shody částek pro minimalizaci zbytku @@ -979,7 +938,6 @@ Snižte vlastní poplatek a zkuste to znovu. Poplatek pod minimální hranicí Zvyšte vlastní poplatek a zkuste to znovu. - MINIMUM Poznámka bitcoin přijatý Peer odpojen. @@ -987,18 +945,8 @@ Přijímat Lightning prostředky Přijmout bitcoin bitcoinová faktura - Chcete-li získat více okamžitých bitcoinů, musí Bitkit navýšit vaši likviditu. Od vámi zadané částky bude odečten síťový poplatek <accent>{networkFee}</accent> a <accent>{serviceFee}</accent> poplatek poskytovatele služeb . - Chcete-li nastavit disponibilní zůstatek, bude odečten síťový poplatek <accent>{networkFee}</accent> a <accent>{serviceFee}</accent> poplatek poskytovateli služeb. Faktura zkopírovaná do schránky Lightningová faktura - Povolte nastavení na pozadí pro bezpečné opuštění Bitkit během konfigurace vašeho zůstatku. - Nastavit na pozadí - Likvidita disponibilního zůstatku - Dodatečná likvidita disponibilního zůstatku - Nastavení disponibilního zůstatku - Zvýšení přijímací kapacity - Váš zůstatek na účtu využívá síť lightning network, aby byly vaše platby levnější, rychlejší a soukromější.\n\nFunguje to jako přístup k internetu, ale místo za šířku pásma platíte za likviditu a směrování.\n\nToto nastavení zahrnuje některé jednorázové náklady. - Váš zůstatek na účtu využívá síť lightning network, aby byly vaše platby levnější, rychlejší a soukromější.\n\nFunguje to jako přístup k internetu, ale místo za šířku pásma platíte za likviditu a směrování.\n\nBitkit potřebuje zvýšit přijímací kapacitu vašeho výdajového zůstatku, aby mohl tuto platbu zpracovat. Nepovinná poznámka pro plátce Užívejte si okamžité a levné\ntransakce s přáteli, rodinou\na obchodníky. Přijímat na disponibilní zůstatek @@ -1008,7 +956,6 @@ Auto Úspory Útraty - Obdržíte Kontakt Vložit fakturu Zadat ručně @@ -1278,7 +1225,6 @@ Ověření adresy se nezdařilo. Zkontrolujte adresu na zařízení a zkuste to znovu. Toto zařízení obsahuje jinou peněženku než tu, kterou jste spárovali. Připojte spárovanou peněženku, nebo tuto přidejte jako novou hardwarovou peněženku. Kanál nenalezen - Převod z HW peněženky Do úspor Částka je příliš nízká pro převod do úspor. Místo toho uzavřít kanál @@ -1287,21 +1233,9 @@ Poplatek za službu Váš převod byl zahájen a <accent>vypořádává se on-chain</accent>. Zůstatek vašich úspor se po dokončení automaticky aktualizuje. Převod\n<accent>je na cestě</accent> - Vaše úspory nepokryjí poplatek za likviditu pro tuto kapacitu příjmu. Zvolte menší částku. - Nedostatek prostředků - Kapacita příjmu je aktuálně omezena na ₿ {amount}. - Maximální kapacita příjmu - Vaše úspory nepokryjí tento převod a jeho poplatky. Zkuste menší částku. - Nedostatečné úspory - Poplatky jsou o {amount} vyšší, protože se během zobrazení této obrazovky změnily sazby poplatků nebo zůstatek vašich úspor. Pro potvrzení přejeďte znovu. - Poplatky jsou nyní, po vytvoření objednávky, o {amount} vyšší. Pro potvrzení přejeďte znovu. - Poplatky se změnily <accent>Připojování</accent>\nk síti - Otevřít Trezor Connect Připojte prosím znovu své hardwarové zařízení, zdá se, že je od telefonu odpojené. Znovu připojte hardwarové zařízení - Podepište\n<accent>svým zařízením</accent> - Transakce\n<accent>podepsána</accent> PŘEVOD PŘIPRAVEN ZA %s Převod není dostupný Automatické převody na zůstatek útrat již nejsou k dispozici. Použijte vlastní uzel nebo si nastavte připojení u jiného poskytovatele. @@ -1662,12 +1596,6 @@ Tento týden Tento rok Dnes - Nabídka likvidity již není platná. Zkuste to znovu a získejte novou nabídku. - Nabídka není k dispozici - Maximum, které teď můžete přijmout do disponibilního zůstatku, je ₿ {amount}. - Maximální kapacita příjmu - Další disponibilní kapacita teď není k dispozici. Zkuste to později, nebo kontaktujte podporu, pokud se to bude opakovat. - Disponibilní kapacita není k dispozici Bitkit nemohl načíst vaše mince. Zkuste to prosím znovu. Částka přesahuje váš dostupný zůstatek. Nedostatečný zůstatek diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f54e62746c..8718cde10c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -36,7 +36,6 @@ Weiter In die Zwischenablage kopiert Kopieren - Standard Löschen Ja, Löschen Abbrechen @@ -47,7 +46,6 @@ Später Mehr erfahren Max - Min Nie Nein OK @@ -101,43 +99,22 @@ Abhängig von der Gebühr Abhängig von der Gebühr Abhängig von der Gebühr - Spending\n<accent>Balance</accent> - Fülle dein Guthaben auf, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu genießen. - Los geht\'s + Anderes Wallet verwenden + Von HW-Wallet übertragen Spending Balance Fund your <accent>spending balance</accent> - Am einfachsten ist es, deine Bitkit-Ersparnisse zu verwenden oder Bitcoin von einer anderen Wallet zu senden. Von Sparkonto übertragen - Anderes Wallet verwenden - Ausgabenkonto - Fortgeschrittene<accent>setup</accent> - Scannen Sie einen QR-Code, um Ihren LNURL-Kanal von einem anderen LSP zu beanspruchen, oder wählen Sie die manuelle Einrichtung. - LNURL Channel QR Manuelle Einrichtung - Keine verfügbaren Mittel - Bevor du Geld von deinem Sparkonto übertragen kannst, musst du Bitcoin zu deiner Bitkit Wallet senden. - Wallet aufladen Übertrage Guthaben Bitte\n<accent>bestätige</accent> Swipe zum Übertragen - <accent>zu den Ausgaben</accent> - Überweisen Sie Geld auf Ihr Guthaben, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu tätigen. - Los geht\'s - <accent>zu den Ausgaben</accent> 25% Maximaler Ausgabensaldo Der Betrag, den du auf dein Ausgabenkonto übertragen kannst, ist derzeit auf ₿ {amount} begrenzt. - Deine Übertragung auf das Ausgabenkonto ist aufgrund der Liquiditätsrichtlinie begrenzt. Für Details besuche das Hilfezentrum. Netzgebühren Servicegebühren Zu den Ausgaben Total - Standardwerte verwenden - Empfang von\n<accent>Kapazität</accent> - Liquiditätsgebühr - Liquidität\n<accent>& Routenplanung</accent> - Your Spending Balance nutzt das Lightning Network, um Ihre Zahlungen günstiger, schneller und privater zu machen.\n\nDies funktioniert wie ein Internetzugang, aber Sie zahlen für Liquidität und Routing statt für Bandbreite.\n\nDiese Einrichtung umfasst einige einmalige Kosten. - Ausgabensaldo Liquidität Übertragung\n<accent>zu den Einsparungen</accent> Übertragen Sie Ihr Ausgabensaldo auf Ihr Sparguthaben, um Ihre Bitcoin langfristig zu speichern und Ihre Zukunft zu sichern. Los geht\'s @@ -154,8 +131,6 @@ Bitkit behalten\n<accent>open</accent> Die Gelder wurden noch nicht übertragen. Bitkit wird versuchen, die Übertragung in den nächsten <accent>30 Minuten</accent> zu initiieren. Bitte halte deine App offen. Übertragung erfolgreich - Bewegte Mittel\n<accent>zu den Ausgaben</accent> - Deine Gelder wurden übertragen und dein Ausgabenguthaben ist einsatzbereit. Bewegte Mittel\n<accent>zu den Einsparungen</accent> Ihr Geld wurde überwiesen. Sie können diese Mittel sofort verwenden. Lightning-Verbindung @@ -176,12 +151,6 @@ Sparkonto Ausgabenguthaben Empfangskapazität - Bitte warte, deine Übertragung wird bearbeitet. Dies sollte <accent>±10 Minuten</accent> dauern. - Zahlung wird verarbeitet - Zahlung erfolgreich - In Warteschlange für Öffnung - Verbindung wird geöffnet - Bitkit weiter verwenden Lightning Node LDK Node ID Lightning Verbindungen @@ -297,7 +266,6 @@ Bitkit gibt dir die Schlüssel zu deinem Geld. Gib es aus oder spare für später. Die Entscheidung liegt bei dir. Instant\n<accent>payments</accent> Gib bitcoin aus, schneller denn je zuvor. Genieße sofortige und günstige Zahlungen mit Freunden, Familie und Händlern. - Bitkit bietet derzeit keine Lightning-Dienste in deinem Land an, aber du kannst dich trotzdem direkt mit anderen Knotenpunkten verbinden. Privacy is\n<accent>not a crime</accent> Wische, um deinen Kontostand zu verbergen, genieße mehr private Zahlungen und schütze dein Wallet, indem du Sicherheitsfunktionen aktivierst. Your keys,\n<accent>your coins</accent> @@ -340,14 +308,6 @@ Sieht so aus, als wärst du nicht verbunden, wiederverbinden... Internetverbindung wiederhergestellt Bitkit wurde erfolgreich mit dem Internet verbunden. - Dein Bitkit-Geschenkcode wird eingelöst... - Geschenk wird eingelöst - Bitkit konnte die Gelder nicht einlösen. Bitte versuche es später erneut oder kontaktiere uns. - Geschenkcode-Fehler - Dieser Bitkit-Geschenkcode wurde bereits verwendet und die Gelder wurden ausgezahlt. - Code verwendet - Entschuldigung, du bist zu spät! Alle Geschenke für diesen Code wurden bereits eingelöst. - Keine Geschenke mehr Hoher Kontostand Hoher\n<accent>Kontostand</accent> <accent>Dein Wallet-Kontostand übersteigt $500.</accent>\nZu deiner Sicherheit solltest du einen Teil deiner Ersparnisse in ein Offline-Wallet verschieben. @@ -607,7 +567,6 @@ In Android Bitkit-Einstellungen anpassen An Aus - Im Hintergrund einrichten Branch and Bound Findet exakte Betragsübereinstimmungen, um Wechselgeld zu minimieren Single Random Draw @@ -974,24 +933,12 @@ Optionale Notiz an den Zahler QR-Code anzeigen Lightning-Gelder empfangen - Um dein Ausgabenguthaben einzurichten, wird eine <accent>{networkFee}</accent> Netzwerkgebühr und <accent>{serviceFee}</accent> Service-Provider-Gebühr abgezogen. - Um mehr sofortiges Bitcoin zu empfangen, muss Bitkit deine Liquidität erhöhen. Eine <accent>{networkFee}</accent> Netzwerkgebühr und <accent>{serviceFee}</accent> Service-Provider-Gebühr werden von dem von dir angegebenen Betrag abgezogen. Empfangen auf <accent>Ausgabenguthaben</accent> Genieße sofortige und günstige\nTransaktionen mit Freunden, Familie\nund Händlern. Details anzeigen Auto Sparkonto Ausgabenkonto - Einrichtung des Ausgabensaldos - Erhöhung der Empfangskapazität - Your Spending Balance nutzt das Lightning Network, um Ihre Zahlungen günstiger, schneller und privater zu machen.\n\nDies funktioniert wie ein Internetzugang, aber Sie zahlen für Liquidität und Routing statt für Bandbreite.\n\nDiese Einrichtung umfasst einige einmalige Kosten. - Your Spending Balance nutzt das Lightning Network, um Ihre Zahlungen günstiger, schneller und privater zu machen.\n\nDies funktioniert wie ein Internetzugang, aber Sie zahlen für Liquidität und Routing statt für Bandbreite.\n\nBitkit muss die Aufnahmekapazität Ihres Guthabens erhöhen, um diese Zahlung zu verarbeiten. - Ausgabensaldo Liquidität - Zusätzliche Ausgaben Saldo Liquidität - Aktiviere die Hintergrund-Einrichtung, um Bitkit sicher zu verlassen, während dein Guthaben konfiguriert wird. - Im Hintergrund einrichten - Du wirst empfangen - MINIMUM Aktivität Alle anzeigen Noch keine Aktivitäten @@ -1275,7 +1222,6 @@ Adressüberprüfung fehlgeschlagen. Überprüfe die Adresse auf deinem Gerät und versuche es erneut. Auf diesem Gerät befindet sich ein anderes Wallet als das gekoppelte. Verbinde das gekoppelte Wallet oder füge dieses als neues Hardware-Wallet hinzu. Kanal nicht gefunden - Von HW-Wallet übertragen Zum Sparkonto Der Betrag ist zu niedrig für eine Übertragung auf das Sparkonto. Stattdessen Kanal schließen @@ -1284,21 +1230,9 @@ Servicegebühr Deine Übertragung wurde gestartet und wird <accent>onchain abgewickelt</accent>. Dein Sparguthaben wird automatisch aktualisiert, sobald sie abgeschlossen ist. Übertragung\n<accent>unterwegs</accent> - Dein Sparkonto reicht nicht aus, um die Liquiditätsgebühr für diese Empfangskapazität zu decken. Wähle einen kleineren Betrag. - Nicht genügend Guthaben - Die Empfangskapazität ist derzeit auf ₿ {amount} begrenzt. - Maximale Empfangskapazität - Dein Sparkonto reicht nicht aus, um diese Übertragung und ihre Gebühren zu decken. Versuche einen kleineren Betrag. - Unzureichendes Sparguthaben - Die Gebühren sind um {amount} höher, weil sich die Gebührensätze oder dein Sparguthaben geändert haben, während du auf diesem Bildschirm warst. Wische erneut, um zu bestätigen. - Die Gebühren sind um {amount} höher, da deine Bestellung jetzt erstellt ist. Wische erneut, um zu bestätigen. - Gebühren geändert <accent>Verbinde</accent>\nmit Netzwerk - Trezor Connect öffnen Bitte verbinde dein Hardware-Gerät erneut, es scheint von deinem Telefon getrennt zu sein. Hardware-Gerät neu verbinden - Mit deinem\n<accent>Gerät signieren</accent> - Transaktion\n<accent>signiert</accent> ÜBERTRAGUNG BEREIT IN %s Übertragung nicht verfügbar Automatische Übertragungen auf dein Ausgabenkonto sind nicht mehr verfügbar. Nutze deinen eigenen Node oder richte eine Verbindung mit einem anderen Anbieter ein. @@ -1659,12 +1593,6 @@ Diese Woche Dieses Jahr Heute - Das Liquiditätsangebot ist nicht mehr gültig. Versuche es erneut, um ein neues Angebot zu erhalten. - Angebot nicht verfügbar - Das Maximum, das du derzeit auf dein Ausgabenkonto empfangen kannst, ist ₿ {amount}. - Maximale Empfangskapazität - Zusätzliche Ausgabenkapazität ist derzeit nicht verfügbar. Versuche es später erneut oder kontaktiere den Support, falls das weiterhin passiert. - Zusätzliche Kapazität nicht verfügbar Bitkit konnte deine Coins nicht laden. Bitte versuche es erneut. Der Betrag übersteigt dein verfügbares Guthaben. Unzureichendes Guthaben diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 2946724699..cc21331e47 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -36,7 +36,6 @@ Συνέχεια Αντιγράφηκε στο πρόχειρο Αντιγραφή - Προεπιλογή Διαγραφή Ναι, Διέγραψε Όχι, Ακύρωσε @@ -47,7 +46,6 @@ Αργότερα Μάθε περισσότερα Μέγιστο - Ελάχιστο Ποτέ Όχι ΟΚ @@ -159,28 +157,18 @@ Αναγκαστική\n<accent>μεταφορά</accent> Μεταφορά από αποταμιεύσεις Χρήση άλλου πορτοφολιού + Μεταφορά από πορτοφόλι HW Υπόλοιπο δαπανών - Ο ευκολότερος τρόπος είναι να χρησιμοποιήσεις τις αποταμιεύσεις Bitkit ή να στείλεις bitcoin από διαφορετικό πορτοφόλι. Χρηματοδότησε το <accent>υπόλοιπο δαπανών</accent> - LNURL Channel QR Χειροκίνητη ρύθμιση - Υπόλοιπο δαπανών - Σάρωσε ένα QR για να διεκδικήσεις το LNURL Channel σου από άλλο LSP ή επίλεξε χειροκίνητη ρύθμιση. - Ρύθμιση <accent>για προχωρημένους</accent> Χωρητικότητα εισερχομένων Μέγιστο εισερχόμενο HTLC Ελάχιστο εισερχόμενο HTLC Είναι χρησιμοποιήσιμο Υπόλοιπα Lightning - Ρευστότητα υπολοίπου δαπανών - Το υπόλοιπο δαπανών χρησιμοποιεί το Lightning Network για να κάνει τις πληρωμές σου φθηνότερες, ταχύτερες και πιο ιδιωτικές.\n\nΛειτουργεί όπως η πρόσβαση στο διαδίκτυο, αλλά πληρώνεις για ρευστότητα & δρομολόγηση αντί για εύρος ζώνης.\n\nΑυτή η ρύθμιση περιλαμβάνει κάποια εφάπαξ κόστη. - Ρευστότητα\n<accent>& δρομολόγηση</accent> Ύψος αρχειοθέτησης παρακολούθησης καναλιού Όριο επόμενου εξερχόμενου HTLC Ελάχιστο επόμενο εξερχόμενο HTLC - Πριν μπορέσεις να μεταφέρεις κεφάλαια από το υπόλοιπο αποταμιεύσεων, πρέπει να στείλεις bitcoin στο πορτοφόλι Bitkit. - Χρηματοδότηση πορτοφολιού - Δεν υπάρχουν διαθέσιμα κεφάλαια LDK Node ID Κόμβος Lightning Χρόνος συγχρονισμού πορτοφολιού Lightning @@ -219,30 +207,15 @@ Περίμενε, η μεταφορά κεφαλαίων σου είναι σε εξέλιξη. Αυτό θα διαρκέσει <accent>±10 δευτερόλεπτα.</accent> Κεφάλαια\n<accent>σε μεταφορά</accent> Χρόνος συγχρονισμού βαθμολογιών εύρεσης διαδρομής - Συνέχισε να χρησιμοποιείς το Bitkit - Επεξεργασία πληρωμής - Επιτυχής πληρωμή - Σε ουρά για άνοιγμα - Άνοιγμα σύνδεσης - Περίμενε, η μεταφορά κεφαλαίων σου είναι σε εξέλιξη. Αυτό θα διαρκέσει <accent>±10 λεπτά.</accent> Διαθέσιμα onchain Δαπάνες - Τέλος ρευστότητας - Χωρητικότητα\n<accent>λήψης</accent> - Ανεπαρκείς αποταμιεύσεις Το ποσό που μπορείς να μεταφέρεις στο υπόλοιπο δαπανών περιορίζεται προς το παρόν σε ₿ {amount}. - Η μεταφορά σου στο υπόλοιπο δαπανών περιορίζεται λόγω πολιτικής ρευστότητας. Για λεπτομέρειες, επισκέψου το Κέντρο Βοήθειας. Μέγιστο υπόλοιπο δαπανών 25% - Μεταφορά\n<accent>σε δαπάνες</accent> Σε δαπάνες - Χρήση προεπιλογών Τέλη υπηρεσίας Τέλη δικτύου Σύνολο - Ξεκίνα - Μετέφερε κεφάλαια στο υπόλοιπο δαπανών για να απολαύσεις άμεσες και φθηνές συναλλαγές με φίλους, οικογένεια και εμπόρους. - Μεταφορά\n<accent>σε δαπάνες</accent> Υπόλοιπο δαπανών Κατάσταση Υποστήριξη @@ -255,14 +228,9 @@ Μεταφορά κεφαλαίων Σύρε για μεταφορά ΜΕΤΑΦΟΡΑ ΣΕ ΕΞΕΛΙΞΗ - Ξεκίνα - Χρηματοδότησε το υπόλοιπο δαπανών για να απολαύσεις άμεσες και φθηνές συναλλαγές με φίλους, οικογένεια και εμπόρους. - Υπόλοιπο\n<accent>δαπανών</accent> Επιτυχής μεταφορά Τα κεφάλαιά σου μεταφέρθηκαν. Μπορείς να χρησιμοποιήσεις αυτά τα κεφάλαια αμέσως. - Τα κεφάλαιά σου μεταφέρθηκαν και το υπόλοιπο δαπανών είναι έτοιμο για χρήση. Κεφάλαια μεταφέρθηκαν\n<accent>σε αποταμιεύσεις</accent> - Κεφάλαια μεταφέρθηκαν\n<accent>σε δαπάνες</accent> Περίμενε το Bitkit να συνδεθεί στο δίκτυο πληρωμών (±10 δευτερόλεπτα). Υπόλοιπα πορτοφολιού Ξεκινήστε @@ -310,7 +278,6 @@ Ελευθερία στην\n<accent>τσέπη σου</accent> Το Bitkit σου δίνει τα κλειδιά για να διαχειριστείς τα χρήματά σου. Ξόδεψε τώρα ή αποταμίευσε για αργότερα. Η επιλογή είναι δική σου. Άμεσες\n<accent>πληρωμές</accent> - *Το Bitkit δεν παρέχει προς το παρόν υπηρεσίες Lightning στη χώρα σου, αλλά μπορείς να συνδεθείς σε άλλους κόμβους. Ξόδεψε bitcoin πιο γρήγορα από ποτέ. Απόλαυσε άμεσες και φθηνές πληρωμές με φίλους, οικογένεια και εμπόρους. Το απόρρητο δεν\n<accent>είναι έγκλημα</accent> Τα χρήματά σας, η ιδιωτικότητά σας. Σύρετε για να κρύψετε το υπόλοιπό σας και ξοδέψτε πιο ιδιωτικά, χωρίς παρακολούθηση δεδομένων και μηδενικό KYC. @@ -335,14 +302,6 @@ Η σύνδεση στο διαδίκτυο αποκαταστάθηκε Προβλήματα σύνδεσης στο διαδίκτυο Φαίνεται ότι είσαι αποσυνδεδεμένος, προσπάθεια επανασύνδεσης... - Εξαργύρωση του κωδικού δώρου Bitkit... - Εξαργύρωση δώρου - Το Bitkit δεν μπόρεσε να εξαργυρώσει τα κεφάλαια. Δοκίμασε ξανά αργότερα ή επικοινώνησε μαζί μας. - Σφάλμα κωδικού δώρου - Αυτός ο κωδικός δώρου Bitkit έχει ήδη χρησιμοποιηθεί και τα κεφάλαια έχουν εκταμιευθεί. - Χρησιμοποιημένος κωδικός - Λυπούμαστε, άργησες! Όλα τα δώρα για αυτόν τον κωδικό έχουν ήδη εξαργυρωθεί. - Εξαντλήθηκαν τα δώρα Μάθε περισσότερα Κατανοητό Υψηλό υπόλοιπο @@ -585,7 +544,6 @@ Ειδοποιήσεις Ανενεργό Ενεργό - Ρύθμιση στο παρασκήνιο Να επιτρέπονται οι ειδοποιήσεις Ειδοποιήσεις Βρίσκει ακριβείς αντιστοιχίσεις ποσού για ελαχιστοποίηση ρέστων @@ -957,7 +915,6 @@ Μείωσε το προσαρμοσμένο τέλος και δοκίμασε ξανά. Τέλος κάτω από το ελάχιστο όριο Αύξησε το προσαρμοσμένο τέλος και δοκίμασε ξανά. - ΕΛΑΧΙΣΤΟ Σημείωση Ελήφθησαν Bitcoin Ο ομότιμος αποσυνδέθηκε. @@ -965,18 +922,8 @@ Λήψη κεφαλαίων Lightning Λήψη Bitcoin Τιμολόγιο Bitcoin - Για να λάβεις περισσότερα άμεσα Bitcoin, το Bitkit πρέπει να αυξήσει τη ρευστότητά σου. Τέλος δικτύου <accent>{networkFee}</accent> και τέλος παρόχου υπηρεσιών <accent>{serviceFee}</accent> θα αφαιρεθούν από το ποσό που καθόρισες. - Για τη ρύθμιση του υπολοίπου δαπανών σου, θα αφαιρεθεί τέλος δικτύου <accent>{networkFee}</accent> και τέλος παρόχου υπηρεσιών <accent>{serviceFee}</accent>. Το τιμολόγιο αντιγράφηκε στο πρόχειρο Τιμολόγιο Lightning - Ενεργοποίησε τη ρύθμιση παρασκηνίου για να κλείσεις με ασφάλεια το Bitkit ενώ ρυθμίζεται το υπόλοιπό σου. - Ρύθμιση στο παρασκήνιο - Ρευστότητα υπολοίπου δαπανών - Πρόσθετη ρευστότητα υπολοίπου δαπανών - Ρύθμιση υπολοίπου δαπανών - Αύξηση χωρητικότητας λήψης - Το υπόλοιπο δαπανών χρησιμοποιεί το Lightning Network για να κάνει τις πληρωμές σου φθηνότερες, ταχύτερες και πιο ιδιωτικές.\n\nΛειτουργεί όπως η πρόσβαση στο διαδίκτυο, αλλά πληρώνεις για ρευστότητα & δρομολόγηση αντί για εύρος ζώνης.\n\nΑυτή η ρύθμιση περιλαμβάνει κάποια εφάπαξ κόστη. - Το υπόλοιπο δαπανών χρησιμοποιεί το Lightning Network για να κάνει τις πληρωμές σου φθηνότερες, ταχύτερες και πιο ιδιωτικές.\n\nΛειτουργεί όπως η πρόσβαση στο διαδίκτυο, αλλά πληρώνεις για ρευστότητα & δρομολόγηση αντί για εύρος ζώνης.\n\nΤο Bitkit πρέπει να αυξήσει τη χωρητικότητα λήψης του υπολοίπου δαπανών σου για να επεξεργαστεί αυτή την πληρωμή. Προαιρετική σημείωση προς τον πληρωτή Απόλαυσε άμεσες και φθηνές\nσυναλλαγές με φίλους, οικογένεια\nκαι εμπόρους. Λήψη σε <accent>υπόλοιπο δαπανών</accent> @@ -986,7 +933,6 @@ Αυτόματο Αποταμιεύσεις Δαπάνες - Θα λάβεις Επαφή Επικόλληση τιμολογίου Χειροκίνητη εισαγωγή @@ -1257,7 +1203,6 @@ Το κανάλι δεν βρέθηκε Τα κεφάλαια στο υπόλοιπο δαπανών σας έχουν μεταφερθεί στις αποταμιεύσεις σας. Η Σύνδεση Έκλεισε - Μεταφορά από πορτοφόλι HW Προς αποταμιεύσεις Το ποσό είναι πολύ χαμηλό για μεταφορά στις αποταμιεύσεις. Κλείσιμο καναλιού αντί αυτού @@ -1266,20 +1211,9 @@ Τέλος υπηρεσίας Η μεταφορά σου ξεκίνησε και <accent>εκκαθαρίζεται onchain</accent>. Το υπόλοιπο αποταμιεύσεων θα ενημερωθεί αυτόματα μόλις ολοκληρωθεί. Η μεταφορά\n<accent>είναι καθ\' οδόν</accent> - Οι αποταμιεύσεις σου δεν επαρκούν για το τέλος ρευστότητας αυτής της χωρητικότητας λήψης. Επίλεξε μικρότερο ποσό. - Ανεπαρκή κεφάλαια - Η χωρητικότητα λήψης περιορίζεται προς το παρόν σε ₿ {amount}. - Μέγιστη χωρητικότητα λήψης - Οι αποταμιεύσεις σου δεν επαρκούν για αυτή τη μεταφορά και τα τέλη της. Δοκίμασε μικρότερο ποσό. - Τα τέλη είναι {amount} υψηλότερα, επειδή τα ποσοστά τελών ή το υπόλοιπο αποταμιεύσεων άλλαξαν ενώ ήσουν σε αυτή την οθόνη. Σύρε ξανά για επιβεβαίωση. - Τα τέλη είναι {amount} υψηλότερα τώρα που δημιουργήθηκε η παραγγελία σου. Σύρε ξανά για επιβεβαίωση. - Τα τέλη άλλαξαν <accent>Σύνδεση</accent>\nστο δίκτυο - Άνοιγμα Trezor Connect Σύνδεσε ξανά τη συσκευή υλικού σου, φαίνεται ότι αποσυνδέθηκε από το τηλέφωνό σου. Επανασύνδεση συσκευής υλικού - Υπόγραψε με\n<accent>τη συσκευή σου</accent> - Η συναλλαγή\n<accent>υπογράφηκε</accent> Η ΜΕΤΑΦΟΡΑ ΕΙΝΑΙ ΕΤΟΙΜΗ ΣΕ %s Μεταφορά μη διαθέσιμη Οι αυτόματες μεταφορές στο υπόλοιπο δαπανών δεν είναι πλέον διαθέσιμες. Χρησιμοποιήστε τον δικό σας κόμβο ή ρυθμίστε σύνδεση με άλλον πάροχο. @@ -1654,12 +1588,6 @@ Αυτή την εβδομάδα Φέτος Σήμερα - Η προσφορά ρευστότητας δεν είναι πλέον έγκυρη. Δοκίμασε ξανά για να λάβεις νέα προσφορά. - Μη διαθέσιμη προσφορά - Το μέγιστο που μπορείς να λάβεις στο υπόλοιπο δαπανών αυτή τη στιγμή είναι ₿ {amount}. - Μέγιστη χωρητικότητα λήψης - Η πρόσθετη χωρητικότητα δαπανών δεν είναι διαθέσιμη αυτή τη στιγμή. Δοκίμασε ξανά αργότερα ή επικοινώνησε με την υποστήριξη αν συνεχίσει να συμβαίνει. - Η χωρητικότητα δαπανών δεν είναι διαθέσιμη Το Bitkit δεν μπόρεσε να φορτώσει τα νομίσματά σου. Δοκίμασε ξανά. Το ποσό υπερβαίνει το διαθέσιμο υπόλοιπό σου. Ανεπαρκές υπόλοιπο diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index d3bb08a67a..d3702c1957 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -36,7 +36,6 @@ Continuar Copiado Al Portapapeles Copiar - Por defecto Borrar Sí, Borrar No, Cancelar @@ -47,7 +46,6 @@ Más tarde Aprender Más Max - Min Nunca No OK @@ -165,28 +163,18 @@ Forzar\n<accent>Transferencia</accent> Transferir desde Ahorros Usar Otro Monedero + Transferir desde billetera HW Saldo de gastos - La forma más fácil es usar sus ahorros en Bitkit o enviar bitcoin desde un monedero diferente. Rellene su <accent>saldo de gastos</accent> - QR de canal LNURL Configuración manual - Saldo de gastos - Escanea un QR para reclamar tu canal LNURL de otro LSP, o elige configuración manual. - Configuración <accent>avanzada</accent> Capacidad entrante HTLC máximo entrante HTLC mínimo entrante ¿Es utilizable? Saldos Lightning - Liquidez del saldo de gasto - Tu saldo de gasto utiliza Lightning Network para hacer tus pagos más baratos, rápidos y privados.\n\nFunciona como el acceso a internet, pero pagas por liquidez y enrutamiento en lugar de ancho de banda.\n\nEsta configuración incluye algunos costes únicos. - Liquidez\n<accent>y enrutamiento</accent> Altura de archivo del monitor de canales Límite del siguiente HTLC saliente Mínimo del siguiente HTLC saliente - Antes de que pueda transferir fondos a su saldo de ahorros, tiene que enviar bitcoin a su monedero Bitkit. - Financiar monedero - No hay fondos disponibles ID del nodo LDK Nodo Lightning Hora de sincronización del monedero Lightning @@ -225,30 +213,15 @@ Por favor espera, tu transferencia de fondos está en curso. Debería tardar <accent>±10 segundos.</accent> Fondos\n<accent>en transferencia</accent> Hora de sincronización de puntuaciones de rutas - Continuar Usando Bitkit - Procesando Pago - Pago exitoso - En cola para apertura - Abriendo Conexión - Por favor espera, tu transferencia de fondos está en curso. Debería tardar <accent>±10 minutos.</accent> Gastable en cadena Gasto - Comisión de liquidez - Capacidad\n<accent>de recepción</accent> - Ahorros Insuficientes La cantidad que puedes transferir a tu saldo de gasto está actualmente limitada a ₿ {amount}. - Tu transferencia al saldo de gasto está limitada debido a la política de liquidez. Para más detalles, visita el Centro de ayuda. Máximo del saldo de gasto 25% - Transferir\n<accent>a gasto</accent> A gasto - Usar valores por defecto Comisión de servicio Comisión de red Total - Empezar - Transfiera fondos a su saldo de gasto para disfrutar de transacciones baratas e instantáneas con amigos, familia y comerciantes. - Transferir\n<accent>a gasto</accent> Saldo para gastos Estatus Soporte @@ -261,14 +234,9 @@ Transferir fondos Deslizar para transferir TRANSFERENCIA EN CURSO - Empezar - Rellene el saldo de gasto para disfrutar de transacciones baratas e instantáneas con amigos, familia y comerciantes. - Saldo de\n<accent>Gasto</accent> Transferencia exitosa Tus fondos han sido transferidos. Puedes usar estos fondos inmediatamente. - Tus fondos han sido transferidos y tu saldo de gasto está listo para usar. Fondos movidos\n<accent>a ahorros</accent> - Fondos movidos\n<accent>a gasto</accent> Por favor, espere a que Bitkit se conecte a la red de pagos (±10 segundos). Saldos del monedero Comenzar @@ -316,7 +284,6 @@ Libertad en\n<accent>tu bolsillo</accent> Bitkit te da las llaves para gestionar tu dinero. Gasta ahora o ahorra para después. La elección es tuya. Pagos\n<accent>instantáneos</accent> - *Bitkit actualmente no ofrece servicios Lightning en tu país, pero aún puedes conectarte a otros nodos. Gasta bitcoin más rápido que nunca. Disfruta de pagos instantáneos y baratos con amigos, familia y comerciantes. La privacidad\n<accent>no es un crimen</accent> Tu dinero, tu privacidad. Desliza para ocultar tu saldo y gasta de forma más privada, sin rastreo de datos y sin KYC. @@ -341,14 +308,6 @@ Conexión a Internet Restaurada Problemas de Conectividad de Internet Parece que estás desconectado, intentando reconectar... - Reclamando tu código de regalo Bitkit... - Reclamando regalo - Bitkit no pudo reclamar los fondos. Por favor, inténtalo más tarde o contáctanos. - Error del código de regalo - Este código de regalo Bitkit ya ha sido usado y los fondos han sido pagados. - Código usado - ¡Lo siento, llegaste tarde! Todos los regalos de este código ya han sido reclamados. - Sin regalos Aprender Más Entendido Saldo alto @@ -603,7 +562,6 @@ Notificaciones Desactivado Activado - Configurar en segundo plano Permitir notificaciones Notificaciones Encuentra coincidencias exactas para minimizar el cambio @@ -977,7 +935,6 @@ Reduce la comisión personalizada e inténtalo de nuevo. Comisión Por Debajo del Límite Mínimo Aumenta la comisión personalizada e inténtalo de nuevo. - MÍNIMO Nota Bitcoin recibido Par desconectado. @@ -985,18 +942,8 @@ Recibir fondos Lightning Recibir Bitcoin Factura bitcoin - Para recibir más Bitcoin instantáneo, Bitkit tiene que aumentar tu liquidez. Se deducirán una comisión de red de <accent>{networkFee}</accent> y una comisión del proveedor de servicios de <accent>{serviceFee}</accent> de la cantidad especificada. - Para configurar tu saldo de gasto, se deducirán una comisión de red de <accent>{networkFee}</accent> y una comisión del proveedor de servicios de <accent>{serviceFee}</accent>. Factura copiada al portapapeles Factura Lightning - Activa la configuración en segundo plano para salir de Bitkit de forma segura mientras se configura tu saldo. - Configurar en segundo plano - Liquidez del saldo de gasto - Liquidez adicional del saldo de gasto - Configuración del saldo de gasto - Incrementar Capacidad de Recepción - Tu saldo de gasto utiliza Lightning Network para hacer tus pagos más baratos, rápidos y privados.\n\nFunciona como el acceso a internet, pero pagas por liquidez y enrutamiento en lugar de ancho de banda.\n\nEsta configuración incluye algunos costes únicos. - Tu saldo de gasto utiliza Lightning Network para hacer tus pagos más baratos, rápidos y privados.\n\nFunciona como el acceso a internet, pero pagas por liquidez y enrutamiento en lugar de ancho de banda.\n\nBitkit necesita aumentar la capacidad de recepción de tu saldo de gasto para procesar este pago. Nota opcional para el pagador Disfruta de transacciones instantáneas y baratas\ncon amigos, familia\ny comerciantes. Recibe en el <accent>saldo de gasto</accent> @@ -1006,7 +953,6 @@ Automático Ahorro Gasto - Recibirás Contactar Pegar factura Introducir manualmente @@ -1273,7 +1219,6 @@ Este dispositivo contiene un monedero distinto del que vinculaste. Conecta el monedero vinculado o añade este como un nuevo monedero de hardware. Canal no encontrado Los fondos de tu saldo de gasto se han transferido a tus ahorros. - Transferir desde billetera HW A ahorros La cantidad es demasiado baja para transferirla a ahorros. Cerrar el canal en su lugar @@ -1282,20 +1227,9 @@ Comisión de servicio Tu transferencia se ha iniciado y se está <accent>liquidando en cadena</accent>. Tu saldo de ahorro se actualizará automáticamente cuando se complete. Transferencia\n<accent>en camino</accent> - Tus ahorros no cubren la comisión de liquidez de esta capacidad de recepción. Elige una cantidad menor. - Fondos insuficientes - La capacidad de recepción está limitada actualmente a ₿ {amount}. - Capacidad de recepción máxima - Tus ahorros no cubren esta transferencia y sus comisiones. Prueba con una cantidad menor. - Las comisiones son {amount} más altas porque las tasas de comisión o tu saldo de ahorro cambiaron mientras estabas en esta pantalla. Desliza de nuevo para confirmar. - Las comisiones son {amount} más altas ahora que se ha creado tu pedido. Desliza de nuevo para confirmar. - Las comisiones han cambiado <accent>Conectando</accent>\na la red - Abrir Trezor Connect Vuelve a conectar tu dispositivo de hardware; parece que se ha desconectado de tu teléfono. Reconecta el dispositivo de hardware - Firma con\n<accent>tu dispositivo</accent> - Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s Transferencia no disponible Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. @@ -1657,12 +1591,6 @@ Esta semana Este año Hoy - La cotización de liquidez ya no es válida. Inténtalo de nuevo para obtener una nueva cotización. - Cotización no disponible - El máximo que puedes recibir en tu saldo de gasto en este momento es ₿ {amount}. - Capacidad de recepción máxima - No hay capacidad de gasto adicional disponible en este momento. Inténtalo más tarde o contacta con soporte si sigue ocurriendo. - Capacidad de gasto no disponible Bitkit no pudo cargar tus monedas. Inténtalo de nuevo. La cantidad supera tu saldo disponible. Saldo insuficiente diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4efcbb6f51..a0b6cb8d5a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -51,7 +51,6 @@ Mostrar Detalles Búsqueda Guardar - Por defecto Borrar Sí, Borrar OK @@ -65,7 +64,6 @@ Aprender Más Entendido Conectar - Min Max Nunca ¡Genial!\n¡Bien!\n¡Guay!\n¡Estupendo!\n¡Fantástico!\n¡Perfecto!\n¡Excelente!\n¡Magnífico! @@ -133,17 +131,13 @@ Hora de actualización de caché de comisiones Comisiones No se pueden transferir los fondos de vuelta a ahorros. Por favor, inténtalo de nuevo. - QR de Canal LNURL + Usar Otro Monedero + Transferir desde billetera HW Configuración Manual - Escanea un QR para reclamar tu canal LNURL de otro LSP, o elige la configuración manual. - Configuración <accent>avanzada</accent> Capacidad de entrada HTLC de entrada máximo HTLC de entrada mínimo Saldos Lightning - Liquidez del Saldo de Gastos - Tu Saldo de Gastos usa la Lightning Network para hacer tus pagos más baratos, rápidos y privados.\n\nFunciona como el acceso a internet, pero pagas por liquidez y enrutamiento en lugar de ancho de banda.\n\nEsta configuración incluye algunos costes únicos. - Liquidez\n<accent>y enrutamiento</accent> Altura de archivo del monitor de canal Límite del siguiente HTLC saliente Mínimo del siguiente HTLC saliente @@ -160,41 +154,20 @@ Por favor espera, la transferencia de tus fondos está en progreso. Esto debería llevar <accent>±10 segundos.</accent> Fondos\n<accent>en transferencia</accent> Hora de sincronización de puntuaciones de rutas - Pago Exitoso - En Cola para Apertura - Por favor espera, la transferencia de tus fondos está en progreso. Esto debería llevar <accent>±10 minutos.</accent> Gastable onchain - Comisión de liquidez - Capacidad de\n<accent>recepción</accent> - Ahorros Insuficientes La cantidad que puedes transferir a tu saldo de gastos está actualmente limitada a ₿ {amount}. - Tu transferencia al saldo de gastos está limitada debido a la política de liquidez. Para más detalles, visita el Centro de Ayuda. Máximo del Saldo de Gastos - Transferir\n<accent>a gastos</accent> A gastos - Usar valores por defecto - Transferir\n<accent>a gastos</accent> Reserva total de canales ancla Total Lightning Total onchain TRANSFERENCIA EN PROGRESO - Saldo de\n<accent>Gasto</accent> - Rellene el saldo de gasto para disfrutar de transacciones baratas e instantáneas con amigos, familia y comerciantes. - Empezar Saldo de gastos Rellene su <accent>saldo de gastos</accent> - La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde un monedero diferente. Transferir desde Ahorros - Usar Otro Monedero - Saldo de gastos - No hay fondos disponibles - Antes de que pueda transferir fondos a su saldo de ahorros, tiene que enviar bitcoin a su monedero Bitkit. - Financiar monedero Transferir fondos Por favor,\n<accent>confirme</accent> Deslizar para transferir - Transfiera fondos a su saldo de gasto para disfrutar de transacciones baratas e instantáneas con amigos, familia y comerciantes. - Empezar 25% Comisión de red Comisión de servicio @@ -209,9 +182,7 @@ Los fondos todavía no se transfirieron. Bitkit intentará iniciar la transferencia durante los próximos <accent>30 minutos</accent>. Por favor, mantenga la aplicación abierta. Transferencia exitosa Tus fondos han sido transferidos. Puedes usar estos fondos inmediatamente. - Tus fondos han sido transferidos y tu saldo de gastos está listo para usar. Fondos movidos\n<accent>a ahorros</accent> - Fondos movidos\n<accent>a gastos</accent> Saldos del Monedero Conexión Lightning Servidor @@ -222,9 +193,6 @@ Ahorro Saldo para gastos Capacidad de recepción - Procesando Pago - Abriendo Conexión - Continuar Usando Bitkit Nodo Lightning ID del nodo LDK Conexiones Lightning @@ -292,7 +260,6 @@ Libertad en\n<accent>tu bolsillo</accent> Bitkit te da las llaves para gestionar tu dinero. Gasta ahora o ahorra para después. La elección es tuya. Pagos\n<accent>instantáneos</accent> - *Bitkit no ofrece actualmente servicios Lightning en tu país, pero puedes conectarte a otros nodos. Gasta bitcoin más rápido que nunca. Disfruta de pagos instantáneos y baratos con amigos, familia y comerciantes. La privacidad\n<accent>no es un crimen</accent> Tu dinero, tu privacidad. Desliza para ocultar tu saldo y gasta de forma más privada, sin rastreo de datos y sin KYC. @@ -365,14 +332,6 @@ Permite el acceso a la cámara para escanear facturas bitcoin y pagar más rápido. ESCANEAR\n<accent>CÓDIGO QR</accent> Escanear QR - Reclamando tu código de regalo Bitkit... - Reclamando Regalo - Bitkit no pudo reclamar los fondos. Por favor, inténtalo más tarde o contáctanos. - Error del Código de Regalo - Este código de regalo de Bitkit ya ha sido usado y los fondos han sido pagados. - Código Usado - ¡Lo siento, llegas tarde! Todos los regalos de este código ya han sido reclamados. - Sin Regalos Saldo Alto Saldo\n<accent>Alto</accent> Inicio de Sesión Fallido (LNURL) @@ -625,7 +584,6 @@ Notificaciones Desactivado Activado - Configurar en segundo plano Permitir notificaciones Notificaciones Encuentra coincidencias de cantidad exacta para minimizar el cambio @@ -965,22 +923,10 @@ Recibir en <accent>saldo de gastos</accent> Mostrar Detalles Mostrar código QR - Activa la configuración en segundo plano para salir de Bitkit de forma segura mientras se configura tu saldo. - Configurar en segundo plano - Liquidez del Saldo de Gastos - Liquidez Adicional del Saldo de Gastos - Configuración del Saldo de Gastos - Incrementar Capacidad de Recepción - Tu Saldo de Gastos usa la Lightning Network para hacer tus pagos más baratos, rápidos y privados.\n\nFunciona como el acceso a internet, pero pagas por liquidez y enrutamiento en lugar de ancho de banda.\n\nEsta configuración incluye algunos costes únicos. - Tu Saldo de Gastos usa la Lightning Network para hacer tus pagos más baratos, rápidos y privados.\n\nFunciona como el acceso a internet, pero pagas por liquidez y enrutamiento en lugar de ancho de banda.\n\nBitkit necesita aumentar la capacidad de recepción de tu saldo de gastos para procesar este pago. - Para recibir más Bitcoin instantáneo, Bitkit tiene que aumentar tu liquidez. Se deducirá una comisión de red de <accent>{networkFee}</accent> y una comisión del proveedor de servicio de <accent>{serviceFee}</accent> de la cantidad que especificaste. - Para configurar tu saldo de gastos, se deducirá una comisión de red de <accent>{networkFee}</accent> y una comisión del proveedor de servicio de <accent>{serviceFee}</accent>. Auto Ahorros Gastos Recibir fondos de Lightning - Recibirás - MÍNIMO Actividad Mostrar todo Aún no hay actividad @@ -1272,7 +1218,6 @@ Canal no encontrado Los fondos de su saldo de gasto han sido transferidos a sus ahorros. Conexión Cerrada - Transferir desde billetera HW A ahorros La cantidad es demasiado baja para transferirla a ahorros. Cerrar el canal en su lugar @@ -1281,20 +1226,9 @@ Comisión de servicio Su transferencia se ha iniciado y se está <accent>liquidando onchain</accent>. Su saldo de ahorro se actualizará automáticamente cuando se complete. Transferencia\n<accent>en camino</accent> - Sus ahorros no cubren la comisión de liquidez de esta capacidad de recepción. Elija una cantidad menor. - Fondos insuficientes - La capacidad de recepción está limitada actualmente a ₿ {amount}. - Capacidad de recepción máxima - Sus ahorros no cubren esta transferencia y sus comisiones. Pruebe con una cantidad menor. - Las comisiones son {amount} más altas porque las tasas de comisión o su saldo de ahorro cambiaron mientras estaba en esta pantalla. Deslice de nuevo para confirmar. - Las comisiones son {amount} más altas ahora que se ha creado su pedido. Deslice de nuevo para confirmar. - Las comisiones han cambiado <accent>Conectando</accent>\na la red - Abrir Trezor Connect Vuelva a conectar su dispositivo de hardware; parece que se ha desconectado de su teléfono. Reconecte el dispositivo de hardware - Firme con\n<accent>su dispositivo</accent> - Transacción\n<accent>firmada</accent> TRANSFERENCIA LISTA EN %s Transferencia no disponible Las transferencias automáticas a tu saldo de gasto ya no están disponibles. Usa tu propio nodo o configura una conexión con otro proveedor. @@ -1657,12 +1591,6 @@ Esta semana Este año Hoy - La cotización de liquidez ya no es válida. Inténtelo de nuevo para obtener una nueva cotización. - Cotización no disponible - El máximo que puede recibir en su saldo de gasto en este momento es ₿ {amount}. - Capacidad de recepción máxima - No hay capacidad de gasto adicional disponible en este momento. Inténtelo más tarde o contacte con soporte si sigue ocurriendo. - Capacidad de gasto no disponible Bitkit no pudo cargar sus monedas. Inténtelo de nuevo. La cantidad supera su saldo disponible. Saldo insuficiente diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 302b4a65f6..d18a608fa8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -67,10 +67,8 @@ En savoir plus Compris Connecter - Min Max Jamais - Défaut Aperçu Prêt Succès @@ -100,44 +98,22 @@ Dépend des frais Dépend des frais Dépend des frais - Dépenses \n<accent>Solde</accent> - Alimentez votre solde de dépenses pour profiter de transactions instantanées et bon marché avec vos amis, votre famille et les commerçants. - Commencer + Utiliser un autre portefeuille + Transfert depuis le portefeuille HW Solde Dépenses Financez votre <accent>solde Dépenses</accent> - Le plus simple est d\'utiliser vos économies Bitkit ou d\'envoyer des bitcoins depuis un autre portefeuille. Transfert depuis l\'épargne - Utiliser un autre portefeuille - Solde Dépenses - <accent>Configuration</accent> avancée - Scannez un QR code pour réclamer votre canal LNURL à un autre LSP, ou choisissez la configuration manuelle. - LNURL Canal QR code Configuration manuelle - Pas de fonds disponibles - Avant de pouvoir transférer des fonds depuis votre solde d\'épargne, vous devez envoyer des bitcoins à votre portefeuille Bitkit. - Approvisionner le portefeuille Transfert de fonds Veuillez\n<accent>confirmer</accent> Glissez pour transférer - Transfert\n<accent>aux dépenses</accent> - Transférez des fonds vers votre solde Dépenses pour profiter de transactions instantanées et moins chers avec vos amis, votre famille et les commerçants. - Commencer - Transfert \n<accent>Dépenses</accent> 25% Seuil Maximum Épargne Le montant que vous pouvez transférer vers votre solde de dépenses est actuellement limité à ₿ {amount}. - Votre transfert vers le solde des dépenses est limité en raison de la politique de liquidité. Pour plus de détails, consultez le Centre d\'aide. Frais de réseau Frais de service Vers Dépenses Total - Utiliser les valeurs par défaut - Recevoir \n<accent>capacité</accent> - Épargne insuffisante - Frais de liquidité - Liquidité\n<accent>& routage</accent> - Votre solde Dépenses, utilise le Lightning Network pour rendre vos paiements moins chers, plus rapides et plus confidentiels.\n\nCela fonctionne comme l\'accès à Internet, mais vous payez pour la liquidité et le routage au lieu de la bande passante.\n\nCette configuration implique des coûts uniques. - Liquidité du Solde des dépenses Transfert \n<accent>vers l\'Épargne</accent> Transférez le solde de votre compte Dépenses vers votre Épargne pour conserver vos bitcoins à long terme. Commencer @@ -154,8 +130,6 @@ Gardez Bitkit \n<accent>ouvert</accent> Les fonds n\'ont pas encore été transférés. Bitkit va essayer d\'initier le transfert au cours des <accent>30</accent> prochaines <accent>minutes</accent>. Veuillez garder votre application ouverte. Transfert réussi - Fonds transférés \n<accent>vers Dépenses</accent> - Vos fonds ont été transférés et votre solde Dépenses est prêt à être utilisé. Fonds transférés \n<accent>à l\'épargne</accent> Vos fonds ont été transférés. Vous pouvez utiliser ces fonds immédiatement. Connexion Lightning @@ -184,12 +158,6 @@ Total Onchain TRANSFERT EN COURS Soldes du portefeuille - Veuillez patienter, le transfert des fonds est en cours. Cela devrait prendre <accent>±10 minutes</accent>. - Traitement des paiements - Paiement réussi - En attente d\'ouverture - Ouverture de la connexion - Continuer à utiliser Bitkit Nœud Lightning ID du nœud LDK Heure de synchronisation du portefeuille Lightning @@ -297,7 +265,6 @@ Bitkit vous donne les clés pour contrôler votre argent, votre profil et vos contacts. Prenez le contrôle et devenez sans frontières. Paiements \n<accent>Instantanés</accent> Dépensez des bitcoins plus rapidement que jamais. Profitez de paiements instantanés et bon marché avec vos amis, votre famille et les commerçants. - *Bitkit ne fournit pas actuellement de services Lightning dans votre pays, mais vous pouvez toujours vous connecter à d\'autres nœuds. La vie privée \n<accent>n\'est pas un crime</accent> Votre argent, votre vie privée. Balayez pour masquer votre solde et dépensez de manière plus privée, sans suivi de données et sans KYC. Vos clés, \n<accent>votre argent</accent> @@ -423,14 +390,6 @@ Mise à jour Bitkit ne peut pas lire les données fournies. Cette facture Lightning a expiré. - Réclamer un cadeau - Réclamer votre code cadeau Bitkit... - Erreur de code cadeau - Bitkit n\'a pas pu réclamer les fonds. Veuillez réessayer plus tard ou nous contacter. - Code utilisé - Ce code cadeau Bitkit a déjà été utilisé et les fonds ont été versés. - Désolé, vous arrivez trop tard ! Tous les cadeaux pour ce code ont déjà été réclamés. - Plus de cadeaux Boutique Passez à la norme Bitcoin. Dépensez vos bitcoins en cartes-cadeaux numériques, eSIM, recharges téléphoniques, et plus encore. Boutique @@ -637,7 +596,6 @@ Notifications Désactivé Activé - Configurer en arrière-plan Autoriser les notifications Notifications Besoin d\'aide ? Signalez votre problème depuis Bitkit, visitez le centre d\'aide, vérifiez le statut ou contactez-nous sur nos canaux sociaux. @@ -971,16 +929,6 @@ Facture Lightning Note facultative à destination du payeur Afficher le QR code - Pour établir votre solde Dépenses, un <accent>{networkFee}</accent> frais de réseau et <accent>{serviceFee}</accent> frais de fournisseur de services seront déduits. - Pour recevoir plus de bitcoins instantanés, Bitkit doit augmenter vos liquidités. Des frais de réseau de <accent>{networkFee}</accent> et des frais de fournisseur de services de <accent>{serviceFee}</accent> seront déduits du montant que vous avez spécifié. - Configuration du solde compte Dépenses - Augmenter la capacité de réception - Votre solde Dépenses utilise le Lightning Network pour rendre vos paiements moins chers, plus rapides et plus confidentiels.\n\nCela fonctionne comme l\'accès à Internet, mais vous payez pour la liquidité et le routage au lieu de la bande passante.\n\nCette configuration implique des coûts uniques. - Votre solde Dépenses utilise le Lightning Network pour rendre vos paiements moins chers, plus rapides et plus confidentiels.\n\nCela fonctionne comme l\'accès à Internet, mais vous payez pour la liquidité et le routage au lieu de la bande passante.\n\nBitkit a besoin d\'augmenter la capacité de réception de votre solde du compte courant pour traiter ce paiement. - Liquidité disponible - Liquidité supplémentaire du solde Dépenses - Vous recevrez - MINIMUM Activité Tout afficher Pas encore d\'activité @@ -1088,8 +1036,6 @@ Pair déconnecté. Erreur de démarrage de Lightning Recevoir des fonds Lightning - Activez la configuration en arrière-plan pour quitter Bitkit en toute sécurité pendant la configuration de votre solde. - Configurer en arrière-plan Profitez de transactions instantanées\net peu coûteuses avec vos amis, votre\nfamille et les commerçants. Recevoir sur le <accent>solde dépenses</accent> Afficher les détails @@ -1275,7 +1221,6 @@ Échec de la vérification de l\'adresse. Vérifiez l\'adresse sur votre appareil et réessayez. Cet appareil contient un autre portefeuille que celui que vous avez associé. Connectez le portefeuille associé ou ajoutez celui-ci comme nouveau portefeuille matériel. Canal introuvable - Transfert depuis le portefeuille HW Vers l\'épargne Le montant est trop faible pour être transféré vers l\'épargne. Fermer plutôt le canal @@ -1284,20 +1229,9 @@ Frais de service Votre transfert a été initié et est <accent>en cours de règlement on-chain</accent>. Votre solde d\'épargne se mettra à jour automatiquement une fois terminé. Transfert\n<accent>en cours</accent> - Votre épargne ne couvre pas les frais de liquidité pour cette capacité de réception. Choisissez un montant plus petit. - Fonds insuffisants - La capacité de réception est actuellement limitée à ₿ {amount}. - Capacité de réception maximale - Votre épargne ne couvre pas ce transfert et ses frais. Essayez un montant plus petit. - Les frais sont plus élevés de {amount}, car les taux de frais ou votre solde d\'épargne ont changé pendant que vous étiez sur cet écran. Glissez à nouveau pour confirmer. - Les frais sont plus élevés de {amount} maintenant que votre commande est créée. Glissez à nouveau pour confirmer. - Frais modifiés <accent>Connexion</accent>\nau réseau - Ouvrir Trezor Connect Veuillez reconnecter votre appareil matériel, il semble être déconnecté de votre téléphone. Reconnecter l\'appareil matériel - Signez avec\n<accent>votre appareil</accent> - Transaction\n<accent>signée</accent> TRANSFERT PRÊT DANS %s Transfert indisponible Les transferts automatiques vers votre solde de dépenses ne sont plus disponibles. Utilisez votre propre nœud ou configurez une connexion avec un autre fournisseur. @@ -1661,12 +1595,6 @@ Cette semaine Cette année Aujourd\'hui - Le devis de liquidité n\'est plus valide. Réessayez pour obtenir un nouveau devis. - Devis indisponible - Le maximum que vous pouvez recevoir sur votre solde Dépenses pour le moment est de ₿ {amount}. - Capacité de réception maximale - Aucune capacité de dépenses supplémentaire n\'est disponible pour le moment. Réessayez plus tard ou contactez l\'assistance si le problème persiste. - Capacité de dépenses indisponible Bitkit n\'a pas pu charger vos UTXO. Veuillez réessayer. Le montant dépasse votre solde disponible. Solde insuffisant diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 65f988dc0e..0d27e4b38e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -36,7 +36,6 @@ Continua Copiato negli appunti Copia - Predefinito Elimina Si, Elimina No, Annulla @@ -47,7 +46,6 @@ Più tardi Scopri di Più Max - Min Mai No OK @@ -164,28 +162,18 @@ Trasferimento\n<accent>forzoso</accent> Trasferimento da risparmio Usa un altro portafoglio + Trasferimento da wallet HW Conto di Spesa - Il modo più semplice è usare i tuoi risparmi Bitkit o inviare bitcoin da un portafoglio diverso. Manda fondi al tuo <accent>conto di spesa</accent> - QR Canale LNURL Configurazione Manuale - Conto Spendibile - Scansiona un QR per richiedere il tuo canale LNURL da un altro LSP, oppure scegli la configurazione manuale. - Configurazione <accent>avanzata</accent> Capacita\' in Entrata HTLC Max in Entrata HTLC Min in Entrata È utilizzabile? Saldi Lightning - Liquidita\' Conto di Spesa - Il tuo Conto di Spesa utilizza Lightning Network per rendere i tuoi pagamenti piu\' economici, veloci e privati.\n\nFunziona come l\'accesso a Internet, ma paghi per liquidita\' e routing invece che per la banda.\n\nQuesta configurazione include alcuni costi una tantum. - Liquidita\'\n<accent>& routing</accent> Altezza Archivio Monitor Canali Limite HTLC in Uscita Successivo HTLC Min in Uscita Successivo - Prima di poter trasferire fondi dal tuo conto di risparmio, devi inviare bitcoin al tuo portafoglio Bitkit. - Finanzia portafoglio - Nessun fondo disponibile ID Nodo LDK Nodo Lightning Tempo Sincronizzazione Wallet Lightning @@ -224,30 +212,15 @@ Attendi, il trasferimento dei fondi e\' in corso. Dovrebbe richiedere <accent>circa 10 secondi.</accent> Fondi\n<accent>in trasferimento</accent> Tempo Sincronizzazione Punteggi Pathfinding - Continua ad utilizzare Bitkit - Elaborazione del pagamento - Pagamento Riuscito - In Coda Per l\'Apertura - Apertura della Connessione - Attendi, il trasferimento dei fondi è in corso. L\'operazione dovrebbe richiedere <accent>±10 minuti.</accent> Spendibile Onchain Spesa - Commissione liquidita\' - Capacita\'\n<accent>di ricezione</accent> - Risparmi insufficienti L\'importo che puoi trasferire al tuo conto di spesa e\' attualmente limitato a ₿ {amount}. - Il trasferimento al conto di spesa e\' limitato a causa della politica di liquidita\'. Per dettagli, visita il Centro Assistenza. Massimo Conto di Spesa 25% - Trasferisci\n<accent>alle spese</accent> Alle spese - Usa Predefiniti Commissioni servizio Commissioni rete Totale - Inizia - Trasferisci fondi al tuo conto di spesa per usufruire di transazioni istantanee ed economiche con amici, familiari e commercianti. - Trasferisci\n<accent>alle spese</accent> Conto di spesa Stato Supporto @@ -260,14 +233,9 @@ Trasferimento Fondi Swipe Per Trasferire TRASFERIMENTO IN CORSO - Inizia - Manda fondi al tuo conto di spesa per usufruire di transazioni istantanee ed economiche con amici, familiari e commercianti. - Conto\n<accent>di Spesa</accent> Trasferimento Riuscito I tuoi fondi sono stati trasferiti. Puoi utilizzare questi fondi immediatamente. - I tuoi fondi sono stati trasferiti e il tuo conto di spesa è pronto per essere utilizzato. Fondi spostati\n<accent>ai risparmi</accent> - Fondi spostati\n<accent>alle spese</accent> Attendi che Bitkit si connetta alla rete di pagamento (±10 secondi). Saldi Wallet Inizia @@ -315,7 +283,6 @@ La libertà\n<accent>nella tua tasca</accent> Bitkit ti dà le chiavi per controllare i tuoi soldi, il tuo profilo e i tuoi contatti. Prendi il controllo e diventa senza confini. Pagamenti\n<accent>istantanei</accent> - *Bitkit attualmente non fornisce servizi Lightning nel tuo paese, ma puoi comunque connetterti ad altri nodi. Spendi bitcoin più velocemente che mai. Goditi pagamenti immediati ed economici con amici, familiari e commercianti. La privacy\n<accent>non è un crimine</accent> I tuoi soldi, la tua privacy. Scorri per nascondere il saldo e spendi in modo più privato, senza tracciamento dei dati e zero KYC. @@ -340,14 +307,6 @@ Connessione Internet ripristinata Problemi di connettività Internet Sembra che tu sia disconnesso, tentativo di riconnessione... - Riscatto del tuo codice regalo Bitkit... - Riscatto Regalo - Bitkit non e\' riuscito a riscuotere i fondi. Riprova piu\' tardi o contattaci. - Errore Codice Regalo - Questo codice regalo Bitkit e\' gia\' stato usato e i fondi sono stati pagati. - Codice Usato - Spiacenti, sei in ritardo! Tutti i regali per questo codice sono gia\' stati riscossi. - Regali Esauriti Scopri di Più Ho Capito Saldo Elevato @@ -602,7 +561,6 @@ Notifiche Disattivo Attivo - Configura in background Consenti notifiche Notifiche Trova corrispondenze esatte per minimizzare il resto @@ -978,7 +936,6 @@ Diminuisci la commissione personalizzata e riprova. Commissione inferiore al limite minimo Aumenta la commissione personalizzata e riprova. - MINIMO Note Bitcoin ricevuti Peer disconnesso. @@ -986,18 +943,8 @@ Ricevi fondi Lightning Ricevi Bitcoin Bitcoin invoice - Per ricevere piu\' Bitcoin istantanei, Bitkit deve aumentare la tua liquidita\'. Una commissione di rete di <accent>{networkFee}</accent> e una commissione del fornitore di servizio di <accent>{serviceFee}</accent> verranno detratte dall\'importo specificato. - Per configurare il tuo conto di spesa, verra\' detratta una commissione di rete di <accent>{networkFee}</accent> e una commissione del fornitore di servizio di <accent>{serviceFee}</accent>. Fattura copiata negli appunti Lightning invoice - Abilita la configurazione in background per uscire in sicurezza da Bitkit mentre il saldo viene configurato. - Configura in background - Liquidita\' Conto di Spesa - Liquidita\' Aggiuntiva Conto di Spesa - Configurazione Conto di Spesa - Aumenta Capacita\' di Ricezione - Il tuo Conto di Spesa utilizza Lightning Network per rendere i tuoi pagamenti piu\' economici, veloci e privati.\n\nFunziona come l\'accesso a Internet, ma paghi per liquidita\' e routing invece che per la banda.\n\nQuesta configurazione include alcuni costi una tantum. - Il tuo Conto di Spesa utilizza Lightning Network per rendere i tuoi pagamenti piu\' economici, veloci e privati.\n\nFunziona come l\'accesso a Internet, ma paghi per liquidita\' e routing invece che per la banda.\n\nBitkit deve aumentare la capacita\' di ricezione del tuo conto di spesa per elaborare questo pagamento. Nota opzionale per chi paga Goditi transazioni istantanee\ned economiche con amici, familiari\ne commercianti. Ricevi sul <accent>conto di spesa</accent> @@ -1007,7 +954,6 @@ Auto Risparmi Spese - Riceverai Contatto Incolla fattura Inserisci Manualmente @@ -1278,7 +1224,6 @@ Questo dispositivo contiene un wallet diverso da quello associato. Connetti il wallet associato o aggiungi questo come nuovo hardware wallet. Canale non trovato I fondi sul tuo saldo di spesa sono stati trasferiti ai tuoi risparmi. - Trasferimento da wallet HW Ai risparmi L\'importo è troppo basso per essere trasferito ai risparmi. Chiudi invece il canale @@ -1287,20 +1232,9 @@ Commissione di servizio Il tuo trasferimento è stato avviato ed è <accent>in regolamento on-chain</accent>. Il saldo dei tuoi risparmi si aggiornerà automaticamente al termine. Trasferimento\n<accent>in corso</accent> - I tuoi risparmi non coprono la commissione di liquidità per questa capacità di ricezione. Scegli un importo minore. - Fondi Insufficienti - La capacità di ricezione è attualmente limitata a ₿ {amount}. - Capacità di Ricezione Massima - I tuoi risparmi non coprono questo trasferimento e le relative commissioni. Prova un importo minore. - Le commissioni sono aumentate di {amount} perché i tassi di commissione o il saldo dei tuoi risparmi sono cambiati mentre eri su questa schermata. Scorri di nuovo per confermare. - Le commissioni sono aumentate di {amount} ora che il tuo ordine è stato creato. Scorri di nuovo per confermare. - Commissioni cambiate <accent>Connessione</accent>\nalla rete - Apri Trezor Connect Ricollega il tuo dispositivo hardware, sembra essere disconnesso dal telefono. Ricollega il Dispositivo Hardware - Firma con\n<accent>il tuo dispositivo</accent> - Transazione\n<accent>firmata</accent> TRASFERIMENTO PRONTO TRA %s Trasferimento non disponibile I trasferimenti automatici al saldo di spesa non sono più disponibili. Usa il tuo nodo o configura una connessione con un altro fornitore. @@ -1661,12 +1595,6 @@ Questa Settimana Quest\'Anno Oggi - La quotazione di liquidità non è più valida. Riprova per ottenere una nuova quotazione. - Quotazione Non Disponibile - Il massimo che puoi ricevere sul tuo conto di spesa in questo momento è ₿ {amount}. - Capacità di Ricezione Massima - Capacità di spesa aggiuntiva non disponibile in questo momento. Riprova più tardi o contatta l\'assistenza se il problema persiste. - Capacità di Spesa Non Disponibile Bitkit non è riuscito a caricare i tuoi coin. Riprova. L\'importo supera il saldo disponibile. Saldo insufficiente diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 88e6da0580..46f75e9412 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -36,7 +36,6 @@ Doorgaan Gekopieerd naar klembord Kopieer - Standaard Verwijderen Ja, verwijderen Nee, annuleren @@ -47,7 +46,6 @@ Later Meer informatie Max - Min Nooit Nee OK @@ -163,28 +161,18 @@ Forceer\n<accent>overboeking</accent> Overboeken van spaargeld Andere wallet gebruiken + Overboeken van HW-wallet Bestedingssaldo - De makkelijkste optie is om je Bitkit-spaargeld te gebruiken of bitcoin te versturen vanuit een andere wallet. Vul je <accent>bestedingssaldo</accent> - LNURL Kanaal QR Handmatige configuratie - Bestedingssaldo - Scan een QR om je LNURL-kanaal van een andere LSP te claimen, of kies handmatige configuratie. - Geavanceerde <accent>configuratie</accent> Inkomende capaciteit Inkomende HTLC max Inkomende HTLC min Is bruikbaar Lightning-saldi - Bestedingssaldo liquiditeit - Je bestedingssaldo gebruikt het Lightning Network om je betalingen goedkoper, sneller en privater te maken.\n\nDit werkt als internettoegang, maar je betaalt voor liquiditeit & routering in plaats van bandbreedte.\n\nDeze configuratie bevat eenmalige kosten. - Liquiditeit\n<accent>& routering</accent> Kanaalmonitor archiveringshoogte Volgende uitgaande HTLC-limiet Volgende uitgaande HTLC min - Voordat je geld van je spaargeld kunt overboeken, moet je bitcoin naar je Bitkit-wallet sturen. - Wallet financieren - Geen beschikbaar geld LDK Node ID Lightning Node Lightning-wallet synchronisatietijd @@ -223,30 +211,15 @@ Even geduld, je geld wordt overgeboekt. Dit duurt <accent>±10 seconden.</accent> Geld\n<accent>in overboeking</accent> Pathfinding scores synchronisatietijd - Doorgaan met Bitkit - Betaling verwerken - Betaling succesvol - In wachtrij voor openen - Verbinding openen - Even geduld, je overboeking is bezig. Dit duurt <accent>±10 minuten.</accent> Besteedbaar onchain Bestedingssaldo - Liquiditeitsvergoeding - Ontvangst\n<accent>capaciteit</accent> - Onvoldoende spaargeld Het bedrag dat je kunt overboeken naar je bestedingssaldo is momenteel beperkt tot ₿ {amount}. - Je overboeking naar het bestedingssaldo is beperkt vanwege liquiditeitsbeleid. Bezoek het Helpcentrum voor details. Maximum bestedingssaldo 25% - Overboeken\n<accent>naar bestedingssaldo</accent> Naar bestedingssaldo - Standaardinstellingen gebruiken Servicevergoedingen Netwerkvergoedingen Totaal - Aan de slag - Boek geld over naar je bestedingssaldo voor directe en goedkope transacties met vrienden, familie en winkeliers. - Overboeken\n<accent>naar bestedingssaldo</accent> Bestedingssaldo Status Ondersteuning @@ -259,14 +232,9 @@ Geld overboeken Veeg om over te boeken OVERBOEKING BEZIG - Aan de slag - Vul je bestedingssaldo voor directe en goedkope transacties met vrienden, familie en winkeliers. - Bestedings\n<accent>saldo</accent> Overboeking succesvol Je geld is overgeboekt. Je kunt dit geld direct gebruiken. - Je geld is overgeboekt en je bestedingssaldo is klaar voor gebruik. Geld verplaatst\n<accent>naar spaargeld</accent> - Geld verplaatst\n<accent>naar bestedingssaldo</accent> Wacht tot Bitkit verbinding maakt met het betaalnetwerk (±10 seconden). Wallet-saldi Aan de slag @@ -314,7 +282,6 @@ Vrijheid in\n<accent>je zak</accent> Bitkit geeft u de sleutels om uw geld, profiel en contacten te beheren. Neem de controle en word grenzeloos. Directe\n<accent>betalingen</accent> - *Bitkit biedt momenteel geen Lightning-services in jouw land, maar je kunt nog steeds verbinding maken met andere nodes. Besteed bitcoin sneller dan ooit. Geniet van directe en goedkope betalingen met vrienden, familie en winkeliers. Privacy is\n<accent>geen misdaad</accent> Uw geld, uw privacy. Veeg om uw saldo te verbergen en besteed meer privé, zonder datatracking en zonder KYC. @@ -339,14 +306,6 @@ Internetverbinding hersteld Internetverbindingsproblemen Je lijkt niet verbonden te zijn, proberen opnieuw te verbinden... - Je Bitkit-cadeaucode wordt geclaimd... - Cadeau claimen - Bitkit kon het geld niet claimen. Probeer het later opnieuw of neem contact met ons op. - Cadeaucode-fout - Deze Bitkit-cadeaucode is al gebruikt en het geld is uitbetaald. - Gebruikte code - Sorry, je bent te laat! Alle cadeaus voor deze code zijn al geclaimd. - Cadeaus op Meer informatie Begrepen Hoog saldo @@ -600,7 +559,6 @@ Notificaties Uit Aan - Op achtergrond instellen Meldingen toestaan Meldingen Vindt exacte bedragovereenkomsten om wisselgeld te minimaliseren @@ -973,7 +931,6 @@ Verlaag de aangepaste vergoeding en probeer het opnieuw. Vergoeding onder minimumlimiet Verhoog de aangepaste vergoeding en probeer het opnieuw. - MINIMUM Notitie Bitcoin ontvangen Peer niet verbonden. @@ -981,18 +938,8 @@ Lightning-geld ontvangen Bitcoin ontvangen Bitcoin-factuur - Om meer directe Bitcoin te ontvangen, moet Bitkit je liquiditeit verhogen. Een netwerkvergoeding van <accent>{networkFee}</accent> en serviceprovidervergoeding van <accent>{serviceFee}</accent> worden afgetrokken van het door jou opgegeven bedrag. - Om je bestedingssaldo in te stellen, wordt een netwerkvergoeding van <accent>{networkFee}</accent> en serviceprovidervergoeding van <accent>{serviceFee}</accent> afgetrokken. Factuur gekopieerd naar klembord Lightning-factuur - Schakel achtergrondinstelling in om Bitkit veilig te verlaten terwijl je saldo wordt geconfigureerd. - Op achtergrond instellen - Bestedingssaldo liquiditeit - Extra bestedingssaldo liquiditeit - Bestedingssaldo instellen - Ontvangstcapaciteit verhogen - Je bestedingssaldo gebruikt het Lightning Network om je betalingen goedkoper, sneller en privater te maken.\n\nDit werkt als internettoegang, maar je betaalt voor liquiditeit & routering in plaats van bandbreedte.\n\nDeze configuratie bevat eenmalige kosten. - Je bestedingssaldo gebruikt het Lightning Network om je betalingen goedkoper, sneller en privater te maken.\n\nDit werkt als internettoegang, maar je betaalt voor liquiditeit & routering in plaats van bandbreedte.\n\nBitkit moet de ontvangstcapaciteit van je bestedingssaldo verhogen om deze betaling te verwerken. Optionele notitie voor betaler Geniet van directe en goedkope\ntransacties met vrienden, familie\nen winkeliers. Ontvangen op <accent>bestedingssaldo</accent> @@ -1002,7 +949,6 @@ Auto Spaargeld Bestedingssaldo - Je ontvangt Contact Factuur plakken Handmatig invoeren @@ -1274,7 +1220,6 @@ Kanaal niet gevonden Het geld op uw bestedingssaldo is overgemaakt naar uw spaargeld. Verbinding Gesloten - Overboeken van HW-wallet Naar spaargeld Bedrag is te laag om naar spaargeld over te boeken. In plaats daarvan kanaal sluiten @@ -1283,20 +1228,9 @@ Servicevergoeding Uw overboeking is gestart en wordt <accent>on-chain afgehandeld</accent>. Uw spaargeld wordt automatisch bijgewerkt zodra deze is voltooid. Overboeking\n<accent>onderweg</accent> - Uw spaargeld is niet voldoende voor de liquiditeitsvergoeding voor deze ontvangstcapaciteit. Kies een kleiner bedrag. - Onvoldoende saldo - De ontvangstcapaciteit is momenteel beperkt tot ₿ {amount}. - Maximale ontvangstcapaciteit - Uw spaargeld is niet voldoende voor deze overboeking en de vergoedingen. Probeer een kleiner bedrag. - De vergoedingen zijn {amount} hoger, omdat de vergoedingstarieven of uw spaargeld zijn gewijzigd terwijl u op dit scherm was. Veeg opnieuw om te bevestigen. - De vergoedingen zijn {amount} hoger nu uw bestelling is aangemaakt. Veeg opnieuw om te bevestigen. - Vergoedingen gewijzigd <accent>Verbinden</accent>\nmet netwerk - Trezor Connect openen Verbind uw hardwareapparaat opnieuw, de verbinding met uw telefoon lijkt verbroken. Hardwareapparaat opnieuw verbinden - Onderteken met\n<accent>uw apparaat</accent> - Transactie\n<accent>ondertekend</accent> OVERBOEKING KLAAR OVER %s Overboeking niet beschikbaar Automatische overboekingen naar je bestedingssaldo zijn niet meer beschikbaar. Gebruik je eigen node of stel een verbinding in met een andere aanbieder. @@ -1660,12 +1594,6 @@ Deze week Dit jaar Vandaag - De liquiditeitsofferte is niet meer geldig. Probeer het opnieuw voor een nieuwe offerte. - Offerte niet beschikbaar - Het maximum dat u nu op uw bestedingssaldo kunt ontvangen is ₿ {amount}. - Maximale ontvangstcapaciteit - Extra bestedingscapaciteit is momenteel niet beschikbaar. Probeer het later opnieuw of neem contact op met support als dit blijft gebeuren. - Bestedingscapaciteit niet beschikbaar Bitkit kon uw coins niet laden. Probeer het opnieuw. Het bedrag overschrijdt uw beschikbare saldo. Onvoldoende saldo diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e35e50dd5e..fbac023b54 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -69,10 +69,8 @@ Pusty Błąd Nieznany błąd - Min Nigdy Max - Domyślne Podgląd Gotowe Sukces @@ -102,44 +100,22 @@ Zależy od opłaty Zależy od opłaty Zależy od opłaty - Saldo \n<accent>Wydatków</accent> - Zasil swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami. - Rozpocznij + Użyj innego portfela + Prześlij z portfela HW Saldo wydatków Zasil swoje <accent>saldo wydatków</accent> - Najłatwiejszą opcją jest użycie oszczędności Bitkit lub wysłanie bitcoinów z innego portfela. Prześlij z Oszczędności - Użyj innego portfela - Saldo do wydawania - <accent>Ustawienia</accent> zaawansowane - Zeskanuj kod QR, aby odebrać swój kanał LNURL od innego LSP, lub wybierz ręczną konfigurację. - Kod QR kanału LNURL Konfiguracja ręczna - Brak dostępnych środków - Zanim będzie można przelać środki z salda oszczędności, należy wysłać bitcoiny do portfela Bitkit. - Doładuj portfel Prześlij środki Proszę \n<accent>potwierdzić</accent> Przesuń, aby przesłać - Prześlij \n<accent>na saldo wydatków</accent> - Przelewaj środki na swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami. - Rozpocznij - Prześlij \n<accent>na saldo wydatków</accent> 25% Maksymalne saldo wydatków Kwota, którą można przelać na saldo wydatków, jest obecnie ograniczona do ₿ {amount}. - Transfer do salda wydatków jest ograniczony ze względu na politykę płynności. Aby uzyskać szczegółowe informacje, proszę odwiedzić Centrum pomocy. Opłaty sieciowe Opłaty serwisowe Do salda wydatków Razem - Użyj ustawień domyślnych - Pojemność \n<accent>odbiorcza</accent> - Niewystarczające oszczędności - Opłata za płynność - Płynność \n<accent>i routing</accent> - Saldo Wydatków wykorzystuje sieć Lightning, aby Twoje płatności były tańsze, szybsze i bardziej prywatne.\n\nDziała to podobnie jak dostęp do Internetu, ale płacą Państwo za płynność i routing zamiast za przepustowość.\n\nTa konfiguracja obejmuje pewne jednorazowe koszty. - Płynność Salda Wydatków Prześlij \n<accent>na saldo oszczędności</accent> Przenieś saldo wydatków do oszczędności, aby przechowywać Bitcoiny długoterminowo i zabezpieczyć swoją przyszłość. Rozpocznij @@ -156,8 +132,6 @@ Pozostaw Bitkit\n<accent>otwarty</accent> Środki nie zostały jeszcze przelane. Bitkit spróbuje zainicjować transfer w ciągu najbliższych <accent>30 minut</accent>. Proszę pozostawić aplikację otwartą. Transfer środków powiódł się - Środki przeniesione\n<accent>na saldo wydatków</accent> - Środki zostały przeniesione i saldo wydatków jest gotowe do użycia. Środki przeniesione\n<accent>na saldo oszczędności</accent> Środki zostały przelane i są gotowe do natychmiastowego użycia. Połączenie z Lightning @@ -178,12 +152,6 @@ Oszczędności Saldo wydatków Zdolność odbioru - Proszę czekać, transfer środków jest w toku. Powinno to zająć <accent>±10 minut.</accent> - Przetwarzanie płatności - Płatność powiodła się - W kolejce do otwarcia - Otwieranie połączenia - Kontunuuj korzystanie z Bitkit\'a Węzeł Lightning Identyfikator węzła LDK Czas synchronizacji portfela Lightning @@ -299,7 +267,6 @@ Bitkit przekazuje Ci klucze do kontrolowania Twoich pieniędzy, profilu i kontaktów. Przejmij kontrolę i stań się wolnym. Płatności\n<accent>natychmiastowe</accent> Wydawaj bitcoiny szybciej niż kiedykolwiek. Ciesz się natychmiastowymi i tanimi płatnościami z przyjaciółmi, rodziną i sprzedawcami. - Bitkit nie świadczy obecnie usług Lightning w Twoim kraju, ale nadal możesz łączyć się z innymi węzłami. Prywatność nie jest\n<accent>przestępstwem</accent> Twoje pieniądze, Twoja prywatność. Przesuń, aby ukryć saldo i wydawaj bardziej prywatnie, bez śledzenia danych i bez KYC. Twoje klucze,\n<accent>twoje monety</accent> @@ -426,14 +393,6 @@ Ta faktura Lightning wygasła. Udostępnij kod QR przez Podróże - Odbiór prezentu - Odbiór kodu podarunkowego Bitkit... - Błąd kodu podarunkowego - Bitkit nie mógł odebrać środków. Proszę spróbować ponownie później lub skontaktować się z nami. - Użyty kod - Ten kod podarunkowy Bitkit został już wykorzystany, a środki zostały wypłacone. - Przepraszamy, jesteś za późno! Wszystkie prezenty dla tego kodu zostały już odebrane. - Brak prezentów Sklep Przestaw się na standard Bitcoina. Wydawaj swoje Bitcoiny na karty podarunkowe, eSIM-y, doładowania telefonu i inne usługi. Sklep @@ -637,7 +596,6 @@ Powiadomienia Wył. Wł. - Konfiguracja w tle Zezwól na powiadomienia Powiadomienia Znajduje dokładne dopasowania kwot, aby zminimalizować resztę @@ -976,16 +934,6 @@ Faktura Lightning Opcjonalna notatka dla płatnika Skanuj kod QR - Aby skonfigurować saldo wydatków, zostanie pobrana opłata sieciowa <accent>{networkFee}</accent> oraz opłata dostawcy usługi <accent>{serviceFee}</accent> - Bitkit musi zwiększyć Twoją płynność, abyś mógł otrzymywać więcej natychmiastowych płatności. Od wskazanej kwoty zostanie pobrana opłata sieciowa <accent>{networkFee}</accent> i serwisowa <accent>{serviceFee}</accent>. - Konfiguracja salda wydatków - Zwiększenie zdolności odbioru - Saldo Wydatków wykorzystuje sieć Lightning, aby Twoje płatności były tańsze, szybsze i bardziej prywatne.\n\nDziała to podobnie jak dostęp do Internetu, ale płacą Państwo za płynność i routing zamiast za przepustowość.\n\nTa konfiguracja obejmuje pewne jednorazowe koszty. - Saldo Wydatków wykorzystuje sieć Lightning, aby Twoje płatności były tańsze, szybsze i bardziej prywatne.\n\nDziała to podobnie jak dostęp do Internetu, ale płacą Państwo za płynność i routing zamiast za przepustowość.\n\nBitkit musi zwiększyć zdolność odbiorczą Państwa salda wydatków, aby przetworzyć tę płatność. - Saldo wydatków Płynność - Dodatkowa płynność salda wydatków - Otrzymasz - MINIMUM Aktywność Pokaż wszystko Nie ma jeszcze aktywności @@ -1094,8 +1042,6 @@ Węzeł rozłączony. Błąd uruchamiania Lightning Odbierz środki Lightning - Włącz konfigurację w tle, aby bezpiecznie opuścić Bitkit podczas konfigurowania salda. - Konfiguracja w tle Ciesz się natychmiastowymi i tanimi\ntransakcjami z przyjaciółmi, rodziną\ni sprzedawcami. Odbierz na <accent>saldo wydatków</accent> Pokaż szczegóły @@ -1278,7 +1224,6 @@ Weryfikacja adresu nie powiodła się. Sprawdź adres na urządzeniu i spróbuj ponownie. To urządzenie zawiera inny portfel niż sparowany. Połącz sparowany portfel lub dodaj ten jako nowy portfel sprzętowy. Nie znaleziono kanału - Prześlij z portfela HW Na oszczędności Kwota jest zbyt niska, aby przesłać ją na oszczędności. Zamiast tego zamknij kanał @@ -1287,20 +1232,9 @@ Opłata serwisowa Twój transfer został zainicjowany i jest <accent>rozliczany on-chain</accent>. Saldo oszczędności zaktualizuje się automatycznie po jego zakończeniu. Transfer\n<accent>w drodze</accent> - Twoje oszczędności nie pokryją opłaty za płynność dla tej zdolności odbioru. Wybierz mniejszą kwotę. - Za mało środków - Zdolność odbioru jest obecnie ograniczona do ₿ {amount}. - Maksymalna zdolność odbioru - Twoje oszczędności nie pokryją tego transferu i jego opłat. Spróbuj mniejszej kwoty. - Opłaty są wyższe o {amount}, ponieważ stawki opłat lub saldo oszczędności zmieniły się podczas wyświetlania tego ekranu. Przesuń ponownie, aby potwierdzić. - Po utworzeniu zamówienia opłaty są wyższe o {amount}. Przesuń ponownie, aby potwierdzić. - Opłaty się zmieniły <accent>Łączenie</accent>\nz siecią - Otwórz Trezor Connect Połącz ponownie urządzenie sprzętowe, wygląda na to, że zostało odłączone od telefonu. Połącz ponownie urządzenie - Podpisz\n<accent>urządzeniem</accent> - Transakcja\n<accent>podpisana</accent> TRANSFER GOTOWY ZA %s Przesłanie niedostępne Automatyczne przesyłanie środków na saldo wydatków nie jest już dostępne. Użyj własnego węzła lub skonfiguruj połączenie z innym dostawcą. @@ -1661,12 +1595,6 @@ W tym tygodniu W tym roku Dzisiaj - Wycena płynności nie jest już ważna. Spróbuj ponownie, aby uzyskać nową wycenę. - Wycena niedostępna - Maksymalna kwota, jaką możesz teraz otrzymać na saldo wydatków, to ₿ {amount}. - Maksymalna zdolność odbioru - Dodatkowa pojemność wydatków jest teraz niedostępna. Spróbuj ponownie później lub skontaktuj się z pomocą techniczną, jeśli problem się powtarza. - Pojemność wydatków niedostępna Bitkit nie mógł wczytać Twoich monet. Spróbuj ponownie. Kwota przekracza Twoje dostępne saldo. Niewystarczające saldo diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 41ed89d286..b90155fcc1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -36,7 +36,6 @@ Continuar Copiado para Área de Transferência Copiar - Padrão Deletar Sim, Excluir Não, cancelar @@ -47,7 +46,6 @@ Depois Saiba Mais Máx - Mín Nunca Não OK @@ -167,28 +165,18 @@ Forçar\n<accent>Transferência</accent> Transferir do Saldo de Poupança Transferir de Outra Carteira + Transferir da carteira HW Saldo de Gastos - A forma mais fácil é usar suas economias do Bitkit ou enviar bitcoin de uma carteira diferente. Transfira para seu <accent>saldo de gastos</accent> - QR Canal LNURL Setup Manual - Saldo de Gastos - Escaneie um QR para reivindicar seu canal LNURL de outro LSP ou escolha o setup manual. - <accent>Configuração</accent> avançada Capacidade de Entrada HTLC de Entrada Máx HTLC de Entrada Mín É utilizável Saldos Lightning - Liquidez do Saldo de Gastos - Seu Saldo de Gastos usa a Lightning Network para tornar seus pagamentos mais baratos, mais rápidos e mais privados.\n\nIsso funciona como o acesso à Internet, mas você paga por liquidez e roteamento em vez de largura de banda.\n\nEssa configuração inclui alguns custos únicos. - Liquidez\n<accent>e routing</accent> Altura de Arquivamento do Monitor de Canal Limite do Próximo HTLC de Saída HTLC de Saída Mín Próximo - Antes de transferir fundos de seu saldo de poupança, receba bitcoin na sua carteira Bitkit. - Financiar Carteira - Não há Fundos Disponíveis ID do LDK Node Nó Lightning Sincronização da Carteira Lightning @@ -227,30 +215,15 @@ Aguarde, sua transferência de fundos está em andamento. Isso deve levar <accent>±10 segundos.</accent> <accent>Transferindo</accent>\nfundos Sincronização de Scores de Roteamento - Continuar Usando o Bitkit - Processando Pagamento - Pagamento Realizado - Na Fila Para Abertura - Abrindo Conexão - Aguarde, sua transferência de fundos está em andamento. Isso deve levar <accent>±10 minutos.</accent> Disponível Onchain Gastos - Taxa de liquidez - Capacidade de recebimento\n<accent>capacidade</accent> - Saldo de Poupança Insuficiente O valor que você pode transferir para seu saldo de gastos está atualmente limitado a ₿ {amount}. - Sua transferência para o saldo de gastos é limitada devido à política de liquidez. Para obter detalhes, visite a Central de Ajuda. Saldo de gastos Máximo 25% - Transferir\n<accent>para gastos</accent> Para gastos - Usar padrões Taxa de serviço Taxa da rede Total - Começar - Transfira fundos para seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes. - Transferir\n<accent>para gastos</accent> Saldo de gastos Status Suporte @@ -263,14 +236,9 @@ Transf. fundos Deslizar Para Transferir TRANSFERÊNCIA EM ANDAMENTO - Começar - Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes. - Saldo de\n<accent>Gastos</accent> Transferência bem-sucedida Seus fundos foram transferidos. Você já pode usar esses fundos imediatamente. - Seus fundos foram transferidos e seu saldo de gastos está pronto para ser usado. Fundos transferidos\n<accent>para poupança</accent> - Fundos transferidos\n<accent>para gastos</accent> Por favor, espere que a Bitkit se conecte à rede de pagamento (±10 segundos). Saldos da Carteira Começar @@ -318,7 +286,6 @@ Liberdade no\n<accent>seu bolso</accent> O Bitkit entrega as chaves para controlar seu dinheiro, perfil e contatos. Assuma o controle e torne-se sem fronteiras. Pagamentos\n<accent>instantâneos</accent> - *No momento, a Bitkit não fornece serviços Lightning em seu país, mas você ainda pode se conectar a outros nós. Envie bitcoin mais rápido do que nunca. Aproveite os pagamentos instantâneos e baratos com amigos, familiares e comerciantes. Privacidade\n<accent>não é crime</accent> Seu dinheiro, sua privacidade. Deslize para ocultar seu saldo e gaste de forma mais privada, sem rastreamento de dados e zero KYC. @@ -343,14 +310,6 @@ Conexão com a Internet Restaurada. Problemas de Conectividade com a Internet Parece que você está desconectado, tentando reconectar... - Reivindicando seu gitf code Bitkit... - Reivindicando presente - A Bitkit não pôde reivindicar os fundos. Tente novamente mais tarde ou entre em contato conosco. - Erro no código de presente - Este gift code da Bitkit já foi usado e os fundos já foram pagos. - Código usado - Desculpe, você chegou tarde! Todos os presentes deste código já foram reivindicados. - Presentes Esgotados Saiba Mais Entendido Saldo Alto @@ -607,7 +566,6 @@ Notificações Desligado Ligado - Configurar em segundo plano Permitir notificações Notificações Encontra correspondências exatas para minimizar troco @@ -982,7 +940,6 @@ Diminua a taxa personalizada e tente novamente. Taxa abaixo do limite mínimo Aumente a taxa personalizada e tente novamente. - MÍNIMO Nota Bitcoin Recebido Peer desconectado. @@ -990,18 +947,8 @@ Receber fundos Lightning Receber Bitcoin Invoice Bitcoin - Para receber mais Bitcoin instantâneo, a Bitkit precisa aumentar sua liquidez. A taxa de rede de <accent>{networkFee}</accent> e <accent>{serviceFee}</accent> de taxa do provedor de serviços serão deduzidas do valor que você especificou. - Para configurar seu saldo de gastos, uma taxa de rede de <accent>{networkFee}</accent> e <accent>{serviceFee}</accent> de taxa do provedor de serviços serão deduzidas. Invoice copiado para a área de transferência. Invoice Lightning - Ative a configuração em segundo plano para sair da Bitkit com segurança enquanto seu saldo está sendo configurado. - Configurar em segundo plano - Liquidez do Saldo de Gastos - Liquidez adicional do saldo de gastos - Setup do saldo de gastos - Aumentar a capacidade de recebimento - Seu Saldo de Gastos usa a Lightning Network para tornar seus pagamentos mais baratos, mais rápidos e mais privados.\n\nIsso funciona como o acesso à Internet, mas você paga por liquidez e roteamento em vez de largura de banda.\n\nEssa configuração inclui alguns custos únicos. - Seu Saldo de Gastos usa a Lightning Network para tornar seus pagamentos mais baratos, mais rápidos e mais privados.\n\nIsso funciona como o acesso à Internet, mas você paga por liquidez e roteamento em vez de largura de banda.\n\nA Bitkit precisa aumentar a capacidade de recebimento de seu saldo de gastos para processar esse pagamento. Comentário opcional para o pagador Aproveite transações instantâneas\ne baratas com amigos, família\ne comerciantes. Receba no <accent>saldo de gastos</accent> @@ -1011,7 +958,6 @@ Auto Poupança Gastos - Você receberá Contato Colar Invoice Inserir Manualmente @@ -1280,7 +1226,6 @@ Falha na verificação do endereço. Confira o endereço no seu dispositivo e tente novamente. Este dispositivo contém uma carteira diferente da que você pareou. Conecte a carteira pareada ou adicione esta como uma nova carteira de hardware. Canal não encontrado - Transferir da carteira HW Para poupança O valor é baixo demais para transferir para a poupança. Fechar o canal em vez disso @@ -1289,20 +1234,9 @@ Taxa de serviço Sua transferência foi iniciada e está sendo <accent>liquidada onchain</accent>. Seu saldo de poupança será atualizado automaticamente quando ela for concluída. Transferência\n<accent>a caminho</accent> - Sua poupança não cobre a taxa de liquidez para esta capacidade de recebimento. Escolha um valor menor. - Fundos Insuficientes - A capacidade de recebimento está atualmente limitada a ₿ {amount}. - Capacidade Máxima de Recebimento - Sua poupança não cobre esta transferência e suas taxas. Tente um valor menor. - As taxas estão {amount} mais altas porque as taxas de rede ou seu saldo de poupança mudaram enquanto você estava nesta tela. Deslize novamente para confirmar. - As taxas estão {amount} mais altas agora que seu pedido foi criado. Deslize novamente para confirmar. - Taxas alteradas <accent>Conectando</accent>\nà rede - Abrir Trezor Connect Reconecte seu dispositivo de hardware, ele parece estar desconectado do seu telefone. Reconectar Dispositivo de Hardware - Assine com\n<accent>seu dispositivo</accent> - Transação\n<accent>assinada</accent> TRANSFERÊNCIA PRONTA EM %s Transferência indisponível As transferências automáticas para seu saldo de gastos não estão mais disponíveis. Use seu próprio nó ou configure uma conexão com outro provedor. @@ -1661,12 +1595,6 @@ Esta Semana Este Ano Hoje - A cotação de liquidez não é mais válida. Tente novamente para obter uma nova cotação. - Cotação Indisponível - O máximo que você pode receber no seu saldo de gastos agora é ₿ {amount}. - Capacidade Máxima de Recebimento - Capacidade de gastos adicional indisponível no momento. Tente novamente mais tarde ou entre em contato com o suporte se isso continuar acontecendo. - Capacidade de Gastos Indisponível A Bitkit não conseguiu carregar suas moedas. Tente novamente. O valor excede seu saldo disponível. Saldo insuficiente diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index eb0581b85d..ce0bf7577a 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -64,10 +64,8 @@ Saiba Mais Entendido Conectar - Mín Máx Nunca - Padrão Pré-visualização Pronto Sucesso @@ -97,44 +95,22 @@ Depende da taxa Depende da taxa Depende da taxa - Saldo de\n<accent>Gastos</accent> - Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes. - Começar + Transferir de Outra Carteira + Transferir da carteira HW Saldo de Gastos Transfira para seu <accent>saldo de gastos</accent> - A forma mais fácil é usar as suas poupanças Bitkit ou enviar bitcoin de uma carteira diferente. Transferir do Saldo de Poupança - Transferir de Outra Carteira - Saldo de Gastos - <accent>Configuração</accent> avançada - Escaneie um QR para reivindicar seu canal LNURL de outro LSP ou escolha o setup manual. - QR Canal LNURL Setup Manual - Não há Fundos Disponíveis - Antes de transferir fundos de seu saldo de poupança, receba bitcoin na sua carteira Bitkit. - Financiar Carteira Transf. fundos Por favor\n<accent>confirme</accent> Deslizar Para Transferir - Transferir\n<accent>para gastos</accent> - Transfira fundos para seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes. - Começar - Transferir\n<accent>para gastos</accent> 25% Saldo de gastos Máximo O valor que você pode transferir para seu saldo de gastos está atualmente limitado a ₿ {amount}. - Sua transferência para o saldo de gastos é limitada devido à política de liquidez. Para obter detalhes, visite a Central de Ajuda. Taxa da rede Taxa de serviço Para gastos Total - Usar padrões - Capacidade de recebimento\n<accent>capacidade</accent> - Saldo de Poupança Insuficiente - Taxa de liquidez - Liquidez\n<accent>e routing</accent> - Seu Saldo de Gastos usa a Lightning Network para tornar seus pagamentos mais baratos, mais rápidos e mais privados.\n\nIsso funciona como o acesso à Internet, mas você paga por liquidez e roteamento em vez de largura de banda.\n\nEssa configuração inclui alguns custos únicos. - Liquidez do Saldo de Gastos Transferir\n<accent>para poupança</accent> Transfira seu saldo de gastos para sua poupança para armazenar seu Bitcoin a longo prazo e garantir seu futuro. Começar @@ -151,8 +127,6 @@ Mantenha a Bitkit\n<accent>aberta</accent> Os fundos ainda não foram transferidos. A Bitkit tentará iniciar a transferência durante os próximos <accent>30 minutos</accent>. Por favor, mantenha o aplicativo aberto. Transferência bem-sucedida - Fundos transferidos\n<accent>para gastos</accent> - Seus fundos foram transferidos e seu saldo de gastos está pronto para ser usado. Fundos transferidos\n<accent>para poupança</accent> Seus fundos foram transferidos. Você já pode usar esses fundos imediatamente. Conexões Lightning @@ -173,12 +147,6 @@ Poupança Saldo de gastos Capacidade de recebimento - Aguarde, sua transferência de fundos está em andamento. Isso deve levar <accent>±10 minutos.</accent> - Processando Pagamento - Pagamento Realizado - Na Fila Para Abertura - Abrindo Conexão - Continuar Usando o Bitkit Nó Lightning ID do LDK Node Conexões Lightning @@ -292,7 +260,6 @@ A Bitkit lhe dá as chaves para gerenciar seu dinheiro. Gaste agora ou economize para depois. A escolha é sua. Pagamentos\n<accent>instantâneos</accent> Envie bitcoin mais rápido do que nunca. Aproveite os pagamentos instantâneos e baratos com amigos, familiares e comerciantes. - *No momento, a Bitkit não fornece serviços Lightning em seu país, mas você ainda pode se conectar a outros nós. Privacidade\n<accent>não é crime</accent> Deslize seu saldo para ocultá-lo, desfrute de pagamentos mais privados e proteja sua carteira ativando os recursos de segurança. Suas chaves,\n<accent>suas moedas</accent> @@ -392,14 +359,6 @@ Atualizar Bitkit não consegue ler os dados fornecidos. Este invoice lightning expirou. - Reivindicando presente - Reivindicando seu gitf code Bitkit... - Erro no código de presente - A Bitkit não pôde reivindicar os fundos. Tente novamente mais tarde ou entre em contato conosco. - Código usado - Este gift code da Bitkit já foi usado e os fundos já foram pagos. - Desculpe, você chegou tarde demais! Todos os presentes para este código já foram resgatados. - Presentes Esgotados Entrar Entrar em {domain}? Entrar @@ -721,7 +680,6 @@ Notificações Desativado Ativado - Configurar em segundo plano Permitir notificações Notificações Encontra correspondências exatas para minimizar troco @@ -824,16 +782,6 @@ Invoice Lightning Comentário opcional para o pagador Mostrar código QR - Para configurar seu saldo de gastos, uma taxa de rede de <accent>{networkFee}</accent> e <accent>{serviceFee}</accent> de taxa do provedor de serviços serão deduzidas. - Para receber mais Bitcoin instantâneo, a Bitkit precisa aumentar sua liquidez. A taxa de rede de <accent>{networkFee}</accent> e <accent>{serviceFee}</accent> de taxa do provedor de serviços serão deduzidas do valor que você especificou. - Setup do saldo de gastos - Aumentar a capacidade de recebimento - Seu Saldo de Gastos usa a Lightning Network para tornar seus pagamentos mais baratos, mais rápidos e mais privados.\n\nIsso funciona como o acesso à Internet, mas você paga por liquidez e roteamento em vez de largura de banda.\n\nEssa configuração inclui alguns custos únicos. - Seu Saldo de Gastos usa a Lightning Network para tornar seus pagamentos mais baratos, mais rápidos e mais privados.\n\nIsso funciona como o acesso à Internet, mas você paga por liquidez e roteamento em vez de largura de banda.\n\nA Bitkit precisa aumentar a capacidade de recebimento de seu saldo de gastos para processar esse pagamento. - Liquidez do Saldo de Gastos - Liquidez adicional do saldo de gastos - Você receberá - MÍNIMO Atividade Mostrar Tudo Nenhuma atividade ainda @@ -945,8 +893,6 @@ Mostrar Detalhes Receba no <accent>saldo de gastos</accent> Aproveite transações instantâneas e baratas\ncom amigos, familiares\ne comerciantes. - Ative a configuração em segundo plano para sair da Bitkit com segurança enquanto seu saldo está sendo configurado. - Configurar em segundo plano Transação Substituída Sua transação foi substituída por um aumento de taxa Transação Recebida Substituída @@ -1249,7 +1195,6 @@ Canal não encontrado Os fundos do seu saldo de gastos foram transferidos para a sua poupança. Ligação fechada - Transferir da carteira HW Para a poupança O montante é demasiado baixo para transferir para a poupança. Fechar o canal @@ -1258,20 +1203,9 @@ Taxa de serviço A sua transferência foi iniciada e está a ser <accent>liquidada on-chain</accent>. O saldo de poupança será atualizado automaticamente quando terminar. Transferência\n<accent>a caminho</accent> - A sua poupança não cobre a taxa de liquidez para esta capacidade de receção. Escolha um montante menor. - Fundos insuficientes - A capacidade de receção está atualmente limitada a ₿ {amount}. - Capacidade de receção máxima - A sua poupança não cobre esta transferência e as respetivas taxas. Tente um montante menor. - As taxas estão {amount} mais altas porque as taxas de rede ou o seu saldo de poupança mudaram enquanto estava neste ecrã. Deslize novamente para confirmar. - As taxas estão {amount} mais altas agora que o pedido foi criado. Deslize novamente para confirmar. - As taxas mudaram <accent>A ligar</accent>\nà rede - Abrir Trezor Connect Volte a ligar o seu dispositivo de hardware; parece estar desligado do telemóvel. Voltar a ligar o dispositivo - Assine com\n<accent>o seu dispositivo</accent> - Transação\n<accent>assinada</accent> TRANSFERÊNCIA PRONTA EM %s Transferência indisponível As transferências automáticas para o seu saldo de gastos já não estão disponíveis. Use o seu próprio nó ou configure uma ligação com outro fornecedor. @@ -1655,12 +1589,6 @@ Esta semana Este ano Hoje - A cotação de liquidez já não é válida. Tente novamente para obter uma cotação nova. - Cotação indisponível - O máximo que pode receber no seu saldo de gastos neste momento é ₿ {amount}. - Capacidade de receção máxima - Neste momento, não há capacidade de gastos adicional disponível. Tente mais tarde ou contacte o suporte se o problema persistir. - Capacidade de gastos indisponível A Bitkit não conseguiu carregar as suas moedas. Tente novamente. O montante excede o seu saldo disponível. Saldo insuficiente diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fc49a76ef7..bd8a1adc3f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -37,7 +37,6 @@ Продолжить Скопировано в буфер обмена Копировать - По умолчанию Удалить Да, Удалить Нет,\nОтменить @@ -48,7 +47,6 @@ Позже Узнать больше Макс - Мин Никогда Нет ОК @@ -175,32 +173,18 @@ Принудительный\n<accent>Перевод</accent> Перевести из Сбережений Использовать Другой Кошелёк + Перевести с аппаратного кошелька Баланс Расходов - Самый простой вариант — использовать ваши сбережения Bitkit или отправить биткоин с другого кошелька. Пополните свой <accent>Баланс Расходов</accent> - QR LNURL Канала Ручная Настройка - Баланс Расходов - Сканируйте QR-код, чтобы получить свой LNURL Канал от другого LSP, или выберите ручную настройку. - Расширенная <accent>Настройка</accent> Входящая ёмкость Макс. входящий HTLC Мин. входящий HTLC Можно Использовать Балансы Lightning - Ликвидность Баланса Расходов - Ваш баланс расходов использует Lightning Network, чтобы сделать ваши платежи дешевле, быстрее и более приватными. - -Это работает как доступ в интернет, но вы платите за ликвидность и маршрутизацию вместо трафика. - -Эта настройка включает некоторые разовые затраты. - Ликвидность\n<accent>& Маршрутизация</accent> Архивная высота мониторинга канала Лимит след. исходящего HTLC Мин. след. исходящего HTLC - Прежде чем вы сможете перевести средства со своего баланса сбережений, вам нужно отправить биткойны в ваш кошелёк Bitkit. - Пополнить кошелёк - Недостаточно Средств ID Узла LDK Lightning Узел Время синхронизации Lightning кошелька @@ -239,30 +223,15 @@ Пожалуйста, подождите, ваш перевод средств выполняется. Это займёт <accent>±10 секунд.</accent> Средства\n<accent>в Процессе Перевода</accent> Время синхронизации оценок маршрутизации - Продолжить Использование Bitkit - Обработка Платежа - Платеж Успешен - В Очереди на Открытие - Открытие Соединения - Пожалуйста, подождите, ваш перевод средств выполняется. Это займёт <accent>±10 минут.</accent> Доступно Onchain Расходы - Комиссия за Ликвидность - Входящая\n<accent>Ёмкость</accent> - Недостаточно Сбережений Сумма, которую вы можете перевести на баланс расходов, в настоящее время ограничена ₿ {amount}. - Ваш перевод на баланс расходов ограничен из-за политики ликвидности. Подробности см. в Центре помощи. Максимум Баланса Расходов 25% - Перевод\n<accent>на Расходы</accent> На Баланс Расходов - Использовать По Умолчанию Сервисные Комиссии Сетевые Комиссии Итого - Начать - Переведите средства на свой баланс расходов, чтобы наслаждаться мгновенными и недорогими транзакциями с друзьями, семьёй и продавцами. - Перевод\n<accent>на Расходы</accent> Баланс Расходов Статус Поддержка @@ -275,14 +244,9 @@ Перевод Средств Проведите для Перевода ПЕРЕВОД В ПРОЦЕССЕ - Начать - Пополните свой баланс расходов, чтобы наслаждаться мгновенными и недорогими транзакциями с друзьями, семьёй и продавцами. - Расходы\n<accent>Баланс</accent> Перевод Выполнен Успешно Ваши средства переведены. Вы можете использовать эти средства немедленно. - Ваши средства переведены, и ваш баланс расходов готов к использованию. Средства Переведены\n<accent>в Сбережения</accent> - Средства Переведены\n<accent>на Расходы</accent> Пожалуйста, подождите, пока Bitkit подключается к платёжной сети (±10 секунд). Балансы кошелька Начать @@ -331,7 +295,6 @@ Свобода в\n<accent>вашем кармане</accent> Bitkit даёт вам ключи для управления вашими деньгами, профилем и контактами. Возьмите контроль и станьте безграничными. Мгновенные\n<accent>платежи</accent> - *Bitkit в настоящее время не предоставляет услуги Lightning в вашей стране, но вы всё ещё можете подключаться к другим нодам. Тратьте биткойны быстрее, чем когда-либо. Наслаждайтесь мгновенными и недорогими платежами с друзьями, семьёй и продавцами. Конфиденциальность — это\n<accent>не преступление</accent> Ваши деньги, ваша конфиденциальность. Проведите пальцем, чтобы скрыть баланс и тратить более конфиденциально, без отслеживания данных и KYC. @@ -357,14 +320,6 @@ Интернет-Соединение Восстановлено Проблемы с Подключением к Интернету Похоже, вы отключены, пытаемся переподключиться... - Получение вашего подарочного кода Bitkit... - Получение подарка - Bitkit не смог получить средства. Пожалуйста, попробуйте позже или свяжитесь с нами. - Ошибка подарочного кода - Этот подарочный код Bitkit уже был использован, и средства были выплачены. - Использованный код - К сожалению, вы опоздали! Все подарки по этому коду уже получены. - Подарки закончились Узнать Больше Понятно Высокий Баланс @@ -622,7 +577,6 @@ Уведомления Выкл Вкл - Настроить в фоне Разрешить уведомления Уведомления Находит точные совпадения сумм для минимизации сдачи @@ -999,7 +953,6 @@ Уменьшите заданную комиссию и попробуйте снова. Комиссия ниже минимального лимита Увеличьте заданную комиссию и попробуйте снова. - МИНИМУМ Заметка Получен Биткоин Пир отключён. @@ -1007,26 +960,8 @@ Получить Lightning средства Получить Биткоин Биткоин инвойс - Чтобы получить больше мгновенных биткоинов, Bitkit должен увеличить вашу ликвидность. Сетевая комиссия <accent>{networkFee}</accent> и комиссия провайдера услуг <accent>{serviceFee}</accent> будут вычтены из указанной суммы. - Для настройки вашего баланса расходов будет вычтена сетевая комиссия <accent>{networkFee}</accent> и комиссия провайдера услуг <accent>{serviceFee}</accent>. Инвойс скопирован в буфер обмена Лайтнинг инвойс - Включите фоновую настройку, чтобы безопасно выйти из Bitkit, пока настраивается ваш баланс. - Настроить в фоне - Ликвидность Баланса Расходов - Дополнительная Ликвидность Баланса Расходов - Настройка Баланса Расходов - Увеличить Приёмную Мощность - Ваш баланс расходов использует Lightning Network, чтобы сделать ваши платежи дешевле, быстрее и более приватными. - -Это работает как доступ в интернет, но вы платите за ликвидность и маршрутизацию вместо трафика. - -Эта настройка включает некоторые разовые затраты. - Ваш баланс расходов использует Lightning Network, чтобы сделать ваши платежи дешевле, быстрее и более приватными. - -Это работает как доступ в интернет, но вы платите за ликвидность и маршрутизацию вместо трафика. - -Bitkit должен увеличить приёмную ёмкость вашего баланса расходов для обработки этого платежа. Опциональная заметка для плательщика Наслаждайтесь мгновенными и недорогими транзакциями с друзьями, семьёй @@ -1038,7 +973,6 @@ Bitkit должен увеличить приёмную ёмкость ваше Авто Сбережения Расходы - Вы получите Контакт Вставить Инвойс Ввести Вручную @@ -1308,7 +1242,6 @@ Bitkit должен увеличить приёмную ёмкость ваше Проверка адреса не удалась. Сверьте адрес на устройстве и попробуйте снова. На этом устройстве другой кошелёк, а не тот, который вы сопрягли. Подключите сопряжённый кошелёк или добавьте этот как новый аппаратный кошелёк. Канал не найден - Перевести с аппаратного кошелька В сбережения Сумма слишком мала для перевода в сбережения. Вместо этого закрыть канал @@ -1317,20 +1250,9 @@ Bitkit должен увеличить приёмную ёмкость ваше Сервисная комиссия Ваш перевод инициирован и <accent>проводится в блокчейне</accent>. Баланс сбережений обновится автоматически после завершения. Перевод\n<accent>в пути</accent> - Ваших сбережений не хватает на комиссию за ликвидность для этой входящей ёмкости. Выберите меньшую сумму. - Недостаточно Средств - Входящая ёмкость сейчас ограничена ₿ {amount}. - Максимум Входящей Ёмкости - Ваших сбережений не хватает на этот перевод и его комиссии. Попробуйте меньшую сумму. - Комиссии выросли на {amount}, потому что ставки комиссий или баланс сбережений изменились, пока вы были на этом экране. Проведите ещё раз для подтверждения. - После создания заказа комиссии выросли на {amount}. Проведите ещё раз для подтверждения. - Комиссии изменились <accent>Подключение</accent>\nк сети - Открыть Trezor Connect Переподключите аппаратное устройство: похоже, оно отключилось от телефона. Переподключите Аппаратное Устройство - Подпишите на\n<accent>устройстве</accent> - Транзакция\n<accent>подписана</accent> ПЕРЕВОД БУДЕТ ГОТОВ ЧЕРЕЗ %s Перевод недоступен Автоматические переводы на баланс расходов больше недоступны. Используйте собственный узел или настройте подключение к другому провайдеру. @@ -1688,12 +1610,6 @@ Bitkit должен увеличить приёмную ёмкость ваше На Этой Неделе В Этом Году Сегодня - Предложение по ликвидности больше не действительно. Попробуйте снова, чтобы получить новое. - Предложение Недоступно - Максимум, который вы сейчас можете получить на баланс расходов, — ₿ {amount}. - Максимум Входящей Ёмкости - Дополнительная ёмкость для расходов сейчас недоступна. Попробуйте позже или обратитесь в поддержку, если это повторяется. - Ёмкость для Расходов Недоступна Bitkit не смог загрузить ваши монеты. Пожалуйста, попробуйте снова. Сумма превышает ваш доступный баланс. Недостаточно средств diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12a14a4f9e..26c3ba9446 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,7 +65,6 @@ Continue Copied To Clipboard Copy - Default Delete Yes, Delete No, Cancel @@ -77,7 +76,6 @@ Later Learn More Max - Min Never No OK @@ -314,27 +312,16 @@ Use Other Wallet Transfer from HW wallet Spending Balance - The easiest option is to use your Bitkit savings or send bitcoin from a different wallet. Fund your <accent>spending balance</accent> - LNURL Channel QR Manual Setup - Spending Balance - Scan a QR to claim your LNURL Channel from another LSP, or choose manual setup. - Advanced <accent>setup</accent> Inbound Capacity Inbound HTLC Max Inbound HTLC Min Is Usable Lightning Balances - Spending Balance Liquidity - Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. - Liquidity\n<accent>& routing</accent> Channel Monitor Archival Height Next Outbound HTLC Limit Next Outbound HTLC Min - Before you can transfer funds from your savings balance, you need to send bitcoin to your Bitkit wallet. - Fund Wallet - No Available Funds LDK Node ID Lightning Node Lightning Wallet Sync Time @@ -381,38 +368,15 @@ Your transfer has been initiated and is <accent>settling on-chain</accent>. Your savings balance will update automatically once it completes. Transfer\n<accent>on the way</accent> Pathfinding Scores Sync Time - Continue Using Bitkit - Processing Payment - Payment Successful - Queued For Opening - Opening Connection - Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> Spendable Onchain Spending - Your savings cannot cover the liquidity fee for this receiving capacity. Choose a smaller amount. - Not Enough Funds - The receiving capacity is currently limited to ₿ {amount}. - Receiving Capacity Maximum - Liquidity fee - Receiving\n<accent>capacity</accent> - Your savings cannot cover this transfer and its fees. Try a smaller amount. - Insufficient Savings The amount you can transfer to your spending balance is currently limited to ₿ {amount}. - Your transfer to the spending balance is limited due to liquidity policy. For details, visit the Help Center. Spending Balance Maximum 25% - Transfer\n<accent>to spending</accent> To spending - Use Defaults - Fees are {amount} higher because fee rates or your savings balance changed while you were on this screen. Swipe again to confirm. - Fees are {amount} higher now that your order is created. Swipe again to confirm. - Fees changed Service fees Network fees Total - Get Started - Transfer funds to your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. - Transfer\n<accent>to spending</accent> Spending balance Status Support @@ -425,21 +389,13 @@ Please\n<accent>confirm</accent> Transfer Funds Swipe To Transfer - Open Trezor Connect Please reconnect your hardware device, it appears to be disconnected from your phone. Reconnect Hardware Device - Sign with\n<accent>your device</accent> - Transaction\n<accent>signed</accent> TRANSFER IN PROGRESS TRANSFER READY IN %s - Get Started - Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. - Spending\n<accent>Balance</accent> Transfer Successful Your funds have been transferred. You are able to use these funds immediately. - Your funds have been transferred and your spending balance is ready to use. Funds moved\n<accent>to savings</accent> - Funds moved\n<accent>to spending</accent> Transfer Unavailable Automatic transfers to your spending balance are no longer available. Use your own node, or set up a connection with another provider. <accent>Manual setup</accent>\nrequired @@ -490,7 +446,6 @@ Freedom in\n<accent>your pocket</accent> Bitkit hands you the keys to control your money, profile, and contacts. Take charge and become borderless. Instant\n<accent>payments</accent> - *Bitkit does not currently provide Lightning services in your country, but you can still connect to other nodes. Enjoy instant and cheap payments with friends, family, and merchants on the Lightning Network. Privacy is\n<accent>not a crime</accent> Your money, your privacy. Swipe to hide your balance and spend more privately, with no data tracking and zero KYC. @@ -517,14 +472,6 @@ It appears you’re disconnected, trying to reconnect... It appears you\’re disconnected. Please check your connection. Bitkit will try to reconnect every few seconds. Connection\nIssues]]> - Claiming your Bitkit gift code... - Claiming Gift - Bitkit couldn\'t claim the funds. Please try again later or contact us. - Gift Code Error - This Bitkit gift code has already been used, and the funds have been paid out. - Used Code - Sorry, you\'re too late! All gifts for this code have already been claimed. - Out of Gifts Learn More Understood High Balance @@ -924,7 +871,6 @@ Notifications Off On - Set up in background Allow notifications Notifications Finds exact amount matches to minimize change @@ -1445,7 +1391,6 @@ Lower the custom fee and try again. Fee Below Minimum Limit Increase the custom fee and try again. - MINIMUM Note Received Bitcoin The Lightning payment was stopped before it completed. @@ -1531,24 +1476,8 @@ Receive Lightning funds Receive Bitcoin Bitcoin invoice - The liquidity quote is no longer valid. Try again to get a fresh quote. - Quote Unavailable - The maximum you can receive to your spending balance right now is ₿ {amount}. - Receiving Capacity Maximum - Additional spending capacity is unavailable right now. Try again later or contact support if this keeps happening. - Spending Capacity Unavailable - To receive more instant Bitcoin, Bitkit has to increase your liquidity. A <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted from the amount you specified. - To set up your spending balance, a <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted. Invoice copied to clipboard Lightning invoice - Enable background setup to safely exit Bitkit while your balance is being configured. - Set up in background - Spending Balance Liquidity - Additional Spending Balance Liquidity - Spending Balance Setup - Increase Receiving Capacity - Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. - Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nBitkit needs to increase the receiving capacity of your spending balance to process this payment. Optional note to payer Enjoy instant and cheap bitcoin payments on the Lightning Network. Receive on <accent>spending balance</accent> @@ -1558,7 +1487,6 @@ Auto Savings Spending - You will receive Contact Paste Invoice Enter Manually diff --git a/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt b/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt index 8d8a5f1e8f..bdbfcb48ab 100644 --- a/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt +++ b/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt @@ -84,7 +84,6 @@ class BuildOutputContractTest { "E2E_HOMEGATE_URL", "TREZOR_BRIDGE", "TREZOR_BRIDGE_URL", - "GEO", "FEATURE_PAYKIT_UI_DISABLED", "LOCALES", "NETWORK", diff --git a/app/src/test/java/to/bitkit/build/FeesChangedStringsTest.kt b/app/src/test/java/to/bitkit/build/FeesChangedStringsTest.kt deleted file mode 100644 index da8674e16d..0000000000 --- a/app/src/test/java/to/bitkit/build/FeesChangedStringsTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package to.bitkit.build - -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import to.bitkit.R -import kotlin.test.Test -import kotlin.test.assertTrue - -@RunWith(RobolectricTestRunner::class) -@Config(application = Application::class, sdk = [34]) -class FeesChangedStringsTest { - private val context = ApplicationProvider.getApplicationContext() - - @Test - fun `fees changed descriptions contain the amount placeholder`() { - listOf( - R.string.lightning__spending_confirm__fees_changed_service, - R.string.lightning__spending_confirm__fees_changed_network, - ).forEach { id -> - val name = context.resources.getResourceEntryName(id) - assertTrue(context.getString(id).contains("{amount}"), "Missing {amount} in $name") - } - } -} diff --git a/app/src/test/java/to/bitkit/models/CjitQuoteValidatorTest.kt b/app/src/test/java/to/bitkit/models/CjitQuoteValidatorTest.kt deleted file mode 100644 index 26a64af3df..0000000000 --- a/app/src/test/java/to/bitkit/models/CjitQuoteValidatorTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package to.bitkit.models - -import org.junit.Test -import to.bitkit.utils.ServiceError -import kotlin.test.assertIs -import kotlin.test.assertTrue - -class CjitQuoteValidatorTest { - @Test - fun `rejects fee equal to invoice amount`() { - val result = CjitQuoteValidator.validate( - invoiceSat = 10_000u, - feeSat = 10_000u, - channelSizeSat = 20_000u, - ) - - assertIs(result.exceptionOrNull()) - } - - @Test - fun `rejects fee greater than invoice amount`() { - val result = CjitQuoteValidator.validate( - invoiceSat = 10_000u, - feeSat = 10_001u, - channelSizeSat = 20_000u, - ) - - assertIs(result.exceptionOrNull()) - } - - @Test - fun `rejects net receive amount greater than channel size`() { - val result = CjitQuoteValidator.validate( - invoiceSat = 10_000u, - feeSat = 1_000u, - channelSizeSat = 8_999u, - ) - - assertIs(result.exceptionOrNull()) - } - - @Test - fun `accepts valid quote`() { - val result = CjitQuoteValidator.validate( - invoiceSat = 10_000u, - feeSat = 1_000u, - channelSizeSat = 9_000u, - ) - - assertTrue(result.isSuccess) - } -} diff --git a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt index 1275ce35b4..0961142888 100644 --- a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt +++ b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt @@ -11,9 +11,6 @@ class ReceiveLiquidityDecisionTest { source = ReceiveLiquiditySource.SPENDING, invoiceAmountSats = 10_000u, inboundCapacitySats = 1_000u, - minCjitSats = 5_000u, - maxCjitAmountSats = 100_000u, - isGeoBlocked = false, ) @Test @@ -66,7 +63,7 @@ class ReceiveLiquidityDecisionTest { } @Test - fun `zero inbound does not route to additional CJIT`() { + fun `zero inbound needs no additional liquidity`() { assertEquals( ReceiveAdditionalLiquidityAction.None, additionalLiquidityAction( @@ -76,7 +73,7 @@ class ReceiveLiquidityDecisionTest { } @Test - fun `savings and auto edits do not route to CJIT`() { + fun `savings and auto edits need no additional liquidity`() { listOf(ReceiveLiquiditySource.SAVINGS, ReceiveLiquiditySource.AUTO).forEach { assertEquals( ReceiveAdditionalLiquidityAction.None, @@ -88,91 +85,10 @@ class ReceiveLiquidityDecisionTest { } @Test - fun `below CJIT minimum routes to amount picker`() { + fun `spending above inbound routes to transfer unavailable`() { assertEquals( - ReceiveAdditionalLiquidityAction.ChooseAmount, - additionalLiquidityAction( - defaultAdditionalLiquidityParams.copy(invoiceAmountSats = 4_000u) - ) - ) - } - - @Test - fun `at CJIT minimum creates CJIT`() { - assertEquals( - ReceiveAdditionalLiquidityAction.CreateCjit(5_000u), - additionalLiquidityAction( - defaultAdditionalLiquidityParams.copy(invoiceAmountSats = 5_000u) - ) - ) - } - - @Test - fun `over max CJIT amount routes to amount picker`() { - assertEquals( - ReceiveAdditionalLiquidityAction.ChooseAmount, - additionalLiquidityAction( - defaultAdditionalLiquidityParams.copy(invoiceAmountSats = 100_001u) - ) - ) - } - - @Test - fun `unknown max CJIT amount routes to amount picker`() { - assertEquals( - ReceiveAdditionalLiquidityAction.ChooseAmount, - additionalLiquidityAction( - defaultAdditionalLiquidityParams.copy(maxCjitAmountSats = null) - ) - ) - } - - @Test - fun `geo-blocked routes to geo-block screen`() { - assertEquals( - ReceiveAdditionalLiquidityAction.GeoBlocked, - additionalLiquidityAction( - defaultAdditionalLiquidityParams.copy(isGeoBlocked = true) - ) - ) - } - - @Test - fun `CJIT limits are fetched only when additional liquidity can use them`() { - assertFalse( - ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( - source = ReceiveLiquiditySource.AUTO, - invoiceAmountSats = 10_000u, - inboundCapacitySats = 1_000u, - isGeoBlocked = false, - ) - ) - - assertFalse( - ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( - source = ReceiveLiquiditySource.SPENDING, - invoiceAmountSats = 10_000u, - inboundCapacitySats = 0u, - isGeoBlocked = false, - ) - ) - - assertFalse( - ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( - source = ReceiveLiquiditySource.SPENDING, - invoiceAmountSats = 10_000u, - inboundCapacitySats = 1_000u, - isGeoBlocked = true, - ) - ) - - assertTrue( - ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( - source = ReceiveLiquiditySource.SPENDING, - invoiceAmountSats = 10_000u, - inboundCapacitySats = 1_000u, - isGeoBlocked = false, - ) + ReceiveAdditionalLiquidityAction.TransferUnavailable, + additionalLiquidityAction(defaultAdditionalLiquidityParams) ) } diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index d86a32401e..9bd204f94b 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -1,21 +1,12 @@ package to.bitkit.repositories import app.cash.turbine.test -import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.CJitStateEnum -import com.synonym.bitkitcore.CreateOrderOptions import com.synonym.bitkitcore.FundingTx import com.synonym.bitkitcore.IBtChannel -import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo -import com.synonym.bitkitcore.IBtInfoOptions import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before @@ -23,28 +14,17 @@ import org.junit.Test import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.OutPoint import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.AppCacheData -import to.bitkit.data.BlocktankRefundAddress import to.bitkit.data.CacheStore import to.bitkit.models.BlocktankBackupV1 -import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.BlocktankService import to.bitkit.services.CoreService -import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest -import to.bitkit.utils.AppError -import to.bitkit.utils.ServiceError import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -52,10 +32,7 @@ class BlocktankRepoTest : BaseUnitTest() { private val coreService: CoreService = mock() private val blocktankService: BlocktankService = mock() - private val lightningService: LightningService = mock() - private val currencyRepo: CurrencyRepo = mock() private val cacheStore: CacheStore = mock() - private val lightningRepo: LightningRepo = mock() private val cacheData = MutableStateFlow(AppCacheData()) private lateinit var sut: BlocktankRepo @@ -70,12 +47,7 @@ class BlocktankRepoTest : BaseUnitTest() { val transform = it.getArgument<(AppCacheData) -> AppCacheData>(0) cacheData.value = transform(cacheData.value) } - whenever(currencyRepo.currencyState).thenReturn(MutableStateFlow(CurrencyState())) whenever(coreService.blocktank).thenReturn(blocktankService) - whenever { coreService.isGeoBlocked() }.thenReturn(false) - whenever { coreService.isAddressUsed(any()) }.thenReturn(false) - whenever(lightningService.nodeId).thenReturn("node-id") - whenever { lightningService.sign(any()) }.thenReturn("signature") whenever { blocktankService.info(refresh = false) }.thenReturn(mock()) whenever { blocktankService.info(refresh = true) }.thenReturn(mock()) @@ -85,310 +57,17 @@ class BlocktankRepoTest : BaseUnitTest() { whenever { blocktankService.cjitEntries(refresh = false) }.thenReturn(emptyList()) whenever { blocktankService.cjitEntries(refresh = true) }.thenReturn(emptyList()) - whenever { lightningRepo.revealReceiveAddresses(any(), any()) }.thenReturn(Result.success(Unit)) } private fun createSut(): BlocktankRepo { return BlocktankRepo( bgDispatcher = testDispatcher, coreService = coreService, - lightningService = lightningService, - currencyRepo = currencyRepo, cacheStore = cacheStore, enablePolling = false, - lightningRepo = lightningRepo, ) } - @Test - fun `createOrder sends a persisted native SegWit refund address`() = test { - val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) - val order = mock() - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(addressInfo)) - whenever(blocktankService.newOrder(any(), any(), any())).thenReturn(order) - sut = createSut() - - val result = sut.createOrder(spendingBalanceSats = 50_000u, receivingBalanceSats = 100_000u) - - assertEquals(order, result.getOrThrow()) - assertEquals(BlocktankRefundAddress(addressInfo.address, 0), cacheData.value.blocktankRefundAddress) - val options = argumentCaptor() - verify(blocktankService).newOrder(any(), any(), options.capture()) - assertEquals(addressInfo.address, options.firstValue.refundOnchainAddress) - verify(lightningRepo).newAddressInfoForType(AddressType.P2WPKH) - verify(coreService).isAddressUsed(addressInfo.address) - } - - @Test - fun `estimateOrderFee never allocates a refund address`() = test { - val estimate = mock() - whenever(blocktankService.estimateFee(any(), any(), any())).thenReturn(estimate) - sut = createSut() - - repeat(10) { - assertEquals( - estimate, - sut.estimateOrderFee(spendingBalanceSats = 50_000u, receivingBalanceSats = 100_000u) - .getOrThrow(), - ) - } - - verify(lightningRepo, never()).newAddressInfoForType(any()) - verify(lightningRepo, never()).addressInfoForType(any(), any()) - verify(lightningRepo, never()).revealReceiveAddresses(any(), any()) - assertNull(cacheData.value.blocktankRefundAddress) - } - - @Test - fun `failed and repeated orders reuse one unused persisted refund address across repo instances`() = test { - val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) - val order = mock() - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(addressInfo)) - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(addressInfo)) - whenever(blocktankService.newOrder(any(), any(), any())) - .thenThrow(RuntimeException("backend unavailable")) - .thenReturn(order) - sut = createSut() - - assertTrue(sut.createOrder(50_000u).isFailure) - sut = createSut() - repeat(3) { assertEquals(order, sut.createOrder(50_000u).getOrThrow()) } - - verify(lightningRepo, times(1)).newAddressInfoForType(AddressType.P2WPKH) - verify(lightningRepo, times(3)).addressInfoForType(AddressType.P2WPKH, 0) - verify(lightningRepo, times(3)).revealReceiveAddresses(0, AddressType.P2WPKH) - verify(coreService, times(4)).isAddressUsed(addressInfo.address) - } - - @Test - fun `recorded refund payment skips used candidates and rotates to the first unused address`() = test { - val oldInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) - val firstUsedInfo = AddressDerivationInfo(address = "bcrt1qrefund1", index = 1) - val secondUsedInfo = AddressDerivationInfo(address = "bcrt1qrefund2", index = 2) - val unusedInfo = AddressDerivationInfo(address = "bcrt1qrefund3", index = 3) - val order = mock() - cacheData.value = AppCacheData(blocktankRefundAddress = BlocktankRefundAddress(oldInfo.address, 0)) - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(oldInfo)) - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 3)).thenReturn(Result.success(unusedInfo)) - whenever(coreService.isAddressUsed(oldInfo.address)).thenReturn(true) - whenever(coreService.isAddressUsed(firstUsedInfo.address)).thenReturn(true) - whenever(coreService.isAddressUsed(secondUsedInfo.address)).thenReturn(true) - whenever(coreService.isAddressUsed(unusedInfo.address)).thenReturn(false) - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)) - .thenReturn(Result.success(firstUsedInfo)) - .thenReturn(Result.success(secondUsedInfo)) - .thenReturn(Result.success(unusedInfo)) - whenever(blocktankService.newOrder(any(), any(), any())).thenReturn(order) - sut = createSut() - - assertEquals(order, sut.createOrder(50_000u).getOrThrow()) - assertEquals(order, sut.createOrder(50_000u).getOrThrow()) - - verify(lightningRepo, times(3)).newAddressInfoForType(AddressType.P2WPKH) - verify(cacheStore, times(1)).update(any()) - assertEquals(BlocktankRefundAddress(unusedInfo.address, 3), cacheData.value.blocktankRefundAddress) - val options = argumentCaptor() - verify(blocktankService, times(2)).newOrder(any(), any(), options.capture()) - assertEquals(listOf(unusedInfo.address, unusedInfo.address), options.allValues.map { it.refundOnchainAddress }) - } - - @Test - fun `concurrent orders allocate one shared refund address`() = test { - val allocationStarted = CompletableDeferred() - val allowAllocation = CompletableDeferred() - val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) - val order = mock() - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).doSuspendableAnswer { - allocationStarted.complete(Unit) - allowAllocation.await() - Result.success(addressInfo) - } - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(addressInfo)) - whenever(blocktankService.newOrder(any(), any(), any())).thenReturn(order) - sut = createSut() - - val first = async { sut.createOrder(50_000u) } - allocationStarted.await() - val second = async { sut.createOrder(50_000u) } - allowAllocation.complete(Unit) - - assertEquals(order, first.await().getOrThrow()) - assertEquals(order, second.await().getOrThrow()) - verify(lightningRepo, times(1)).newAddressInfoForType(AddressType.P2WPKH) - } - - @Test - fun `refund address allocation failure blocks order submission`() = test { - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)) - .thenReturn(Result.failure(RuntimeException("node persistence failed"))) - sut = createSut() - - assertTrue(sut.createOrder(50_000u).isFailure) - - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `refund address allocation stops after twenty used candidates`() = test { - val oldInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) - val candidates = (1..20).map { - AddressDerivationInfo(address = "bcrt1qrefund$it", index = it) - } - cacheData.value = AppCacheData(blocktankRefundAddress = BlocktankRefundAddress(oldInfo.address, 0)) - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(oldInfo)) - whenever(coreService.isAddressUsed(oldInfo.address)).thenReturn(true) - candidates.forEach { whenever(coreService.isAddressUsed(it.address)).thenReturn(true) } - candidates.fold( - initial = whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)), - ) { stubbing, candidate -> stubbing.thenReturn(Result.success(candidate)) } - sut = createSut() - - val error = assertIs(sut.createOrder(50_000u).exceptionOrNull()) - - assertEquals("Failed to allocate an unused Blocktank refund address", error.message) - verify(lightningRepo, times(20)).newAddressInfoForType(AddressType.P2WPKH) - verify(cacheStore, never()).update(any()) - assertEquals(BlocktankRefundAddress(oldInfo.address, 0), cacheData.value.blocktankRefundAddress) - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `refund address candidate usage lookup failure preserves cached pointer and blocks submission`() = test { - val oldInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) - val candidate = AddressDerivationInfo(address = "bcrt1qrefund1", index = 1) - cacheData.value = AppCacheData(blocktankRefundAddress = BlocktankRefundAddress(oldInfo.address, 0)) - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(oldInfo)) - whenever(coreService.isAddressUsed(oldInfo.address)).thenReturn(true) - whenever(coreService.isAddressUsed(candidate.address)).thenThrow(RuntimeException("activity lookup failed")) - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(candidate)) - sut = createSut() - - assertTrue(sut.createOrder(50_000u).isFailure) - - verify(lightningRepo, times(1)).newAddressInfoForType(AddressType.P2WPKH) - verify(cacheStore, never()).update(any()) - assertEquals(BlocktankRefundAddress(oldInfo.address, 0), cacheData.value.blocktankRefundAddress) - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `refund address allocation rejects a nonadvancing candidate index`() = test { - val oldInfo = AddressDerivationInfo(address = "bcrt1qrefund4", index = 4) - val repeatedIndexCandidate = AddressDerivationInfo(address = "bcrt1qrefund-repeated", index = 4) - cacheData.value = AppCacheData(blocktankRefundAddress = BlocktankRefundAddress(oldInfo.address, 4)) - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 4)).thenReturn(Result.success(oldInfo)) - whenever(coreService.isAddressUsed(oldInfo.address)).thenReturn(true) - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)) - .thenReturn(Result.success(repeatedIndexCandidate)) - sut = createSut() - - assertTrue(sut.createOrder(50_000u).isFailure) - - verify(lightningRepo, times(1)).newAddressInfoForType(AddressType.P2WPKH) - verify(coreService, never()).isAddressUsed(repeatedIndexCandidate.address) - verify(cacheStore, never()).update(any()) - assertEquals(BlocktankRefundAddress(oldInfo.address, 4), cacheData.value.blocktankRefundAddress) - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `refund address allocation cancellation blocks order submission and propagates`() = test { - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).doSuspendableAnswer { - throw CancellationException("cancelled") - } - sut = createSut() - - assertFailsWith { sut.createOrder(50_000u) } - - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `cancellation after refund cache persistence blocks order submission`() = test { - val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(addressInfo)) - whenever { cacheStore.update(any()) }.doSuspendableAnswer { - val transform = it.getArgument<(AppCacheData) -> AppCacheData>(0) - cacheData.value = transform(cacheData.value) - currentCoroutineContext().cancel() - } - sut = createSut() - - val result = async { sut.createOrder(50_000u) } - - assertFailsWith { result.await() } - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `cached refund lookup failure blocks order submission`() = test { - cacheData.value = AppCacheData( - blocktankRefundAddress = BlocktankRefundAddress("bcrt1qrefund0", 0), - ) - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)) - .thenReturn(Result.failure(RuntimeException("lookup failed"))) - sut = createSut() - - assertTrue(sut.createOrder(50_000u).isFailure) - - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `cached refund reveal failure blocks order submission`() = test { - val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) - cacheData.value = AppCacheData(blocktankRefundAddress = BlocktankRefundAddress(addressInfo.address, 0)) - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn(Result.success(addressInfo)) - whenever(lightningRepo.revealReceiveAddresses(0, AddressType.P2WPKH)) - .thenReturn(Result.failure(RuntimeException("reveal failed"))) - sut = createSut() - - assertTrue(sut.createOrder(50_000u).isFailure) - - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `refund cache write failure blocks order submission`() = test { - val addressInfo = AddressDerivationInfo(address = "bcrt1qrefund0", index = 0) - whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn(Result.success(addressInfo)) - whenever { cacheStore.update(any()) }.thenThrow(RuntimeException("cache write failed")) - sut = createSut() - - assertTrue(sut.createOrder(50_000u).isFailure) - - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `invalid cached refund index blocks order submission`() = test { - cacheData.value = AppCacheData( - blocktankRefundAddress = BlocktankRefundAddress("bcrt1qrefund", Int.MAX_VALUE.toLong() + 1), - ) - sut = createSut() - - assertTrue(sut.createOrder(50_000u).isFailure) - - verify(lightningRepo, never()).addressInfoForType(any(), any()) - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - - @Test - fun `cached refund ownership mismatch blocks order submission`() = test { - cacheData.value = AppCacheData( - blocktankRefundAddress = BlocktankRefundAddress("bcrt1qwrongwallet", 0), - ) - whenever(lightningRepo.addressInfoForType(AddressType.P2WPKH, 0)).thenReturn( - Result.success(AddressDerivationInfo(address = "bcrt1qactivewallet", index = 0)), - ) - sut = createSut() - - assertTrue(sut.createOrder(50_000u).isFailure) - - verify(lightningRepo, never()).revealReceiveAddresses(any(), any()) - verify(blocktankService, never()).newOrder(any(), any(), any()) - } - @Test fun `refreshInfo updates state first from cache then server`() = test { sut = createSut() @@ -544,58 +223,6 @@ class BlocktankRepoTest : BaseUnitTest() { } } - @Test - fun `createCjit refreshes max channel size before checking amount`() = test { - sut = createSut() - val staleInfo = btInfo(maxChannelSizeSat = 1_000_000u) - val freshInfo = btInfo(maxChannelSizeSat = 50_000u) - whenever(coreService.blocktank.info(refresh = false)).thenReturn(staleInfo) - whenever(coreService.blocktank.info(refresh = true)).thenReturn(staleInfo, freshInfo) - whenever(coreService.isGeoBlocked()).thenReturn(false) - whenever(lightningService.nodeId).thenReturn("node-id") - - sut.refreshInfo() - val result = sut.createCjit(amountSats = 100_000u) - - assertIs(result.exceptionOrNull()) - verify(coreService.blocktank, times(3)).info(refresh = true) - } - - @Test - fun `createCjit uses cached max channel size when fresh info refresh fails`() = test { - sut = createSut() - val cachedInfo = btInfo(maxChannelSizeSat = 1_000_000u) - whenever(coreService.blocktank.info(refresh = false)).thenReturn(cachedInfo) - whenever(coreService.blocktank.info(refresh = true)).thenReturn(cachedInfo) - .thenThrow(RuntimeException("Network error")) - whenever(coreService.isGeoBlocked()).thenReturn(false) - whenever(lightningService.nodeId).thenReturn("node-id") - - sut.refreshInfo() - val result = sut.createCjit(amountSats = 1_000_001uL) - - assertIs(result.exceptionOrNull()) - verify(coreService.blocktank, times(3)).info(refresh = true) - } - - @Test - fun `toCjitError maps node capacity limit to node capacity error`() { - val error = RuntimeException("Node capacity is above our capacity limit.") - - val result = error.toCjitError() - - assertIs(result) - } - - @Test - fun `toCjitError does not map generic channel size field error to max channel size error`() { - val error = RuntimeException("channelSizeSat must be above minimum") - - val result = error.toCjitError() - - assertEquals(error, result) - } - @Test fun `getOrder returns failure when refresh fails`() { sut = createSut() @@ -806,14 +433,6 @@ class BlocktankRepoTest : BaseUnitTest() { whenever(state).thenReturn(CJitStateEnum.CREATED) } - private fun btInfo(maxChannelSizeSat: ULong): IBtInfo { - val options = mock() - whenever(options.maxChannelSizeSat).thenReturn(maxChannelSizeSat) - return mock().also { - whenever(it.options).thenReturn(options) - } - } - private suspend fun seedCjitEntries(vararg entries: IcJitEntry) { sut.restoreFromBackup( BlocktankBackupV1(createdAt = 0L, orders = emptyList(), cjitEntries = entries.toList()), diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt b/app/src/test/java/to/bitkit/repositories/BtOrderFixtures.kt similarity index 84% rename from app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt rename to app/src/test/java/to/bitkit/repositories/BtOrderFixtures.kt index 80f6981b53..960ab3e8dd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt +++ b/app/src/test/java/to/bitkit/repositories/BtOrderFixtures.kt @@ -1,4 +1,4 @@ -package to.bitkit.ui.screens.transfer +package to.bitkit.repositories import com.synonym.bitkitcore.BtBolt11InvoiceState import com.synonym.bitkitcore.BtOrderState @@ -10,7 +10,6 @@ import com.synonym.bitkitcore.IBtOnchainTransactions import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IBtPayment import com.synonym.bitkitcore.ILspNode -import to.bitkit.viewmodels.TransferToSpendingUiState internal fun previewBtOrder( networkFeeSat: ULong = 2_483UL, @@ -65,13 +64,3 @@ internal fun previewBtOrder( updatedAt = "2025-07-28T08:29:03Z", createdAt = "2025-07-28T08:29:03Z", ) - -internal fun previewSpendingState( - clientBalanceSat: ULong = 967_724UL, - feeSat: ULong = 971_727UL, - lspBalanceSat: ULong = 2_000_000UL, -): TransferToSpendingUiState = TransferToSpendingUiState( - clientBalanceSat = clientBalanceSat, - lspBalanceSat = lspBalanceSat, - feeSat = feeSat, -) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 22d8c5b51a..536c7743e2 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -139,7 +139,6 @@ class LightningRepoTest : BaseUnitTest() { .thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever { electrumProbeService.probe(any(), any()) }.thenReturn(Result.success(Unit)) - whenever(coreService.isGeoBlocked()).thenReturn(false) whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) @@ -605,7 +604,7 @@ class LightningRepoTest : BaseUnitTest() { nodeRunning = true Unit } - whenever(coreService.isGeoBlocked()).doSuspendableAnswer { awaitCancellation() } + whenever(lightningService.disconnectIdlePeers()).doSuspendableAnswer { awaitCancellation() } var result: Result? = null val job = launch { result = sut.start() } @@ -617,20 +616,6 @@ class LightningRepoTest : BaseUnitTest() { verifyBlocking(lightningService, never()) { startEventListener(any()) } } - @Test - fun `start updates geo block state before the node is up`() = test { - stubNodeStatus { false } - whenever(lightningService.node).thenReturn(mock()) - whenever(lightningService.start(anyOrNull(), any())).doSuspendableAnswer { awaitCancellation() } - whenever(coreService.isGeoBlocked()).thenReturn(true) - - val job = launch { sut.start(shouldRetry = false) } - runCurrent() - - assertTrue(sut.lightningState.value.isGeoBlocked) - job.cancelAndJoin() - } - @Test fun `start restores the initial state when cancelled during node setup`() = test { stubNodeStatus { false } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 8e2788cd2f..ed471127ca 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -3149,17 +3149,15 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `refused Blocktank funding is released once transfers are unavailable and keeps Shop proofs`() = test { + fun `refused Blocktank funding is released and keeps Shop proofs`() = test { val attempt = unknownShopAttempt("ab".repeat(32)) .copy(requestId = null, payerIdentity = null, isTransfer = true, orderId = "order-1") val shopProof = startedOnchainProof(unknownShopAttempt("cd".repeat(32))) storedProofs = listOf(shopProof) stubAbandon(attempt) - whenever(lightningRepo.isTransferBlocked()).thenReturn(false) - assertFalse(paymentProofRepo().onchainRequestHasEnded(attempt)) + assertTrue(paymentProofRepo().onchainRequestHasEnded(attempt)) - whenever(lightningRepo.isTransferBlocked()).thenReturn(true) val wallet = FakeReleaseWallet() assertTrue(paymentProofRepo().abandonUnbroadcastOnchainPayment(attempt, wallet) {}.isSuccess) assertEquals(1, wallet.broadcasts) diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index dd9e8823a7..16f4478e61 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -49,7 +49,6 @@ import to.bitkit.models.WalletScope import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest -import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertNotNull diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index e4506b8a6b..8232a6ad57 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -116,7 +116,6 @@ class WalletRepoTest : BaseUnitTest() { @Before fun setUp() = runBlocking { - whenever(coreService.isGeoBlocked()).thenReturn(false) whenever(coreService.decode(INVOICE)).thenReturn(Scanner.Lightning(lightningInvoice(INVOICE))) whenever(coreService.decode(INVOICE_REPLACEMENT)) .thenReturn(Scanner.Lightning(lightningInvoice(INVOICE_REPLACEMENT))) @@ -952,17 +951,24 @@ class WalletRepoTest : BaseUnitTest() { } @Test - fun `opening refresh delayed by geo cannot replace a newer invoice edit`() = test { + fun `opening refresh delayed by its invoice cannot replace a newer invoice edit`() = test { sut.loadFromCache() whenever(coreService.isAddressUsed(any())).thenReturn(false) whenever(lightningRepo.getChannels()).thenReturn(channels) - whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) - .thenReturn(Result.success(INVOICE), Result.success(INVOICE_REPLACEMENT)) - val releaseGeo = CompletableDeferred() - whenever(lightningRepo.updateGeoBlockState()).doSuspendableAnswer { releaseGeo.await() } + val releaseFirstInvoice = CompletableDeferred() + var invoiceCalls = 0 + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).doSuspendableAnswer { + invoiceCalls += 1 + if (invoiceCalls == 1) { + releaseFirstInvoice.await() + Result.success(INVOICE) + } else { + Result.success(INVOICE_REPLACEMENT) + } + } val earlier = launch { sut.refreshReceiveState() } val newer = launch { sut.updateBip21Invoice(SATS, "latest edit") } - releaseGeo.complete(Unit) + releaseFirstInvoice.complete(Unit) earlier.join() newer.join() @@ -1053,15 +1059,22 @@ class WalletRepoTest : BaseUnitTest() { sut.loadFromCache() whenever(coreService.isAddressUsed(any())).thenReturn(false) whenever(lightningRepo.getChannels()).thenReturn(channels) - whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) - .thenReturn(Result.success(INVOICE), Result.success(INVOICE_REPLACEMENT)) - val releaseGeo = CompletableDeferred() - whenever(lightningRepo.updateGeoBlockState()).doSuspendableAnswer { releaseGeo.await() } + val releaseFirstInvoice = CompletableDeferred() + var invoiceCalls = 0 + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).doSuspendableAnswer { + invoiceCalls += 1 + if (invoiceCalls == 1) { + releaseFirstInvoice.await() + Result.success(INVOICE) + } else { + Result.success(INVOICE_REPLACEMENT) + } + } val opening = launch { sut.refreshReceiveState() } val queued = launch { sut.updateBip21Invoice(SATS, "requested") } sut.setBip21Description("typed later") assertTrue(sut.walletState.value.isReceiveInvoiceUpdating) - releaseGeo.complete(Unit) + releaseFirstInvoice.complete(Unit) opening.join() queued.join() diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index cc75279ca8..867008a73f 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -23,7 +23,6 @@ import to.bitkit.ui.components.navigateIfNotCurrent import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.utils.AppError import to.bitkit.viewmodels.RestoreState -import to.bitkit.viewmodels.TransferEffect import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -174,39 +173,6 @@ class ContentViewTest { assertTrue(navigation.shouldNavigate(true, true, true, true)) } - @Test - fun `spending start route uses intro until seen`() { - assertEquals(Routes.SpendingIntro, transferSpendingStartRoute(hasSeenSpendingIntro = false)) - assertEquals(Routes.SpendingAmount, transferSpendingStartRoute(hasSeenSpendingIntro = true)) - } - - @Test - fun `hardware spending start route keeps device id after intro`() { - val deviceId = "trezor-1" - - assertEquals(Routes.SpendingIntroHw(deviceId), transferSpendingStartRoute(false, deviceId)) - assertEquals(Routes.SpendingAmountHw(deviceId), transferSpendingStartRoute(true, deviceId)) - } - - @Test - fun `transfer effect destinations cover funding paid and hw signed`() { - assertEquals(Routes.SettingUp, transferEffectDestination(TransferEffect.OnSpendingFundingPaid)) - assertEquals(Routes.SpendingHwSigned, transferEffectDestination(TransferEffect.OnHwTxSigned)) - assertNull(transferEffectDestination(TransferEffect.OnQuoteReady)) - } - - @Test - fun `funding paid removes spending confirmation from the back stack`() { - val navController = transferNavController() - navController.navigateTo(Routes.SpendingConfirm) - - navController.navigateForTransferEffect(TransferEffect.OnSpendingFundingPaid) - assertTrue(navController.currentDestination?.hasRoute() == true) - - navController.popBackStack() - assertFalse(navController.currentDestination?.hasRoute() == true) - } - @Test fun `a handled root screen link dismisses the open sheet`() { val result = shouldDismissSheetForScreenLink(handled = true, currentSheet = Sheet.Receive()) @@ -231,7 +197,7 @@ class ContentViewTest { @Test fun `receive presentation key changes only between sheet presentations`() { val sheet = Sheet.Receive() - val samePresentation = sheet.copy(route = ReceiveRoute.Amount) + val samePresentation = sheet.copy(route = ReceiveRoute.EditInvoice) val nextPresentation = Sheet.Receive() assertEquals(receiveSheetPresentationKey(sheet), receiveSheetPresentationKey(samePresentation)) @@ -278,17 +244,17 @@ class ContentViewTest { } @Test - fun `savings transfer exit leaves a later transfer to spending alone`() { + fun `savings transfer exit leaves a later transfer screen alone`() { val navController = transferNavController() navController.navigateToTransferSavingsAvailability() navController.navigateTo(Routes.SavingsProgress) navController.navigateOnSavingsTransferExit() navController.navigateTo(Routes.Spending) - navController.navigateTo(Routes.SpendingConfirm) + navController.navigateTo(Routes.Funding) navController.navigateOnSavingsTransferExit() - assertTrue(navController.currentDestination?.hasRoute() == true) + assertTrue(navController.currentDestination?.hasRoute() == true) } private fun transferNavController(): NavHostController = @@ -301,8 +267,7 @@ class ContentViewTest { navigation(startDestination = Routes.SavingsAvailability) { composable {} composable {} - composable {} - composable {} + composable {} } } } diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt index f182503edb..aacdf57527 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt @@ -36,7 +36,7 @@ import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest -import to.bitkit.utils.ServiceError +import to.bitkit.utils.AppError import java.math.BigDecimal import kotlin.test.assertEquals import kotlin.test.assertNull @@ -193,7 +193,7 @@ class AllowancesViewModelTest : BaseUnitTest() { @Test fun `saving without a dollar rate proposes nothing`() = test { whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())) - .thenReturn(Result.failure(ServiceError.CurrencyRateUnavailable())) + .thenReturn(Result.failure(AppError("Currency rate unavailable"))) sut.propose(profile(LEO_KEY, "Leo"), perPaymentUsd = 5, monthlyUsd = 100) {} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/CjitEntryDetailsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/CjitEntryDetailsTest.kt deleted file mode 100644 index 5f4e634271..0000000000 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/CjitEntryDetailsTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package to.bitkit.ui.screens.wallets.receive - -import com.synonym.bitkitcore.IcJitEntry -import org.junit.Test -import to.bitkit.ext.mock -import to.bitkit.utils.ServiceError -import kotlin.test.assertEquals -import kotlin.test.assertIs - -class CjitEntryDetailsTest { - @Test - fun `from rejects fee equal to invoice amount`() { - val entry = IcJitEntry.mock(feeSat = 10_000u, channelSizeSat = 20_000u) - - val result = CjitEntryDetails.from(entry, receiveAmountSats = 10_000u) - - assertIs(result.exceptionOrNull()) - } - - @Test - fun `from rejects fee greater than invoice amount`() { - val entry = IcJitEntry.mock(feeSat = 10_001u, channelSizeSat = 20_000u) - - val result = CjitEntryDetails.from(entry, receiveAmountSats = 10_000u) - - assertIs(result.exceptionOrNull()) - } - - @Test - fun `from rejects net receive amount greater than channel size`() { - val entry = IcJitEntry.mock(feeSat = 1_000u, channelSizeSat = 8_999u) - - val result = CjitEntryDetails.from(entry, receiveAmountSats = 10_000u) - - assertIs(result.exceptionOrNull()) - } - - @Test - fun `from maps valid quote`() { - val entry = IcJitEntry.mock(feeSat = 1_000u, channelSizeSat = 20_000u) - - val result = CjitEntryDetails.from(entry, receiveAmountSats = 10_000u).getOrThrow() - - assertEquals(10_000, result.receiveAmountSats) - assertEquals(1_000, result.feeSat) - assertEquals(20_000, result.channelSizeSat) - assertEquals(entry.invoice.request, result.invoice) - } -} 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 9bacae9389..0fc77baca2 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 @@ -1,7 +1,6 @@ package to.bitkit.ui.screens.wallets.receive import app.cash.turbine.test -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test @@ -11,8 +10,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.ReceiveAdditionalLiquidityAction import to.bitkit.models.ReceiveLiquiditySource -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.BlocktankState import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.wallets.receive.EditInvoiceVM.EditInvoiceScreenEffects @@ -22,13 +19,11 @@ class EditInvoiceVMTest : BaseUnitTest() { private lateinit var sut: EditInvoiceVM private val walletRepo: WalletRepo = mock() - private val blocktankRepo: BlocktankRepo = mock() @Before fun setUp() = runBlocking { - whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState(minCjitSats = 5_000))) whenever(walletRepo.inboundLiquiditySats()).thenReturn(1_000u) - sut = EditInvoiceVM(walletRepo, blocktankRepo) + sut = EditInvoiceVM(walletRepo) } @Test @@ -37,7 +32,6 @@ class EditInvoiceVMTest : BaseUnitTest() { sut.onClickContinue( source = ReceiveLiquiditySource.AUTO, amountSats = 10_000u, - isGeoBlocked = false, ) assertEquals( @@ -50,38 +44,16 @@ class EditInvoiceVMTest : BaseUnitTest() { } @Test - fun `onClickContinue should emit choose amount for spending below CJIT minimum`() = test { - whenever(blocktankRepo.maxCjitAmountSats()).thenReturn(Result.success(100_000u)) - - sut.editInvoiceEffect.test { - sut.onClickContinue( - source = ReceiveLiquiditySource.SPENDING, - amountSats = 4_000u, - isGeoBlocked = false, - ) - - assertEquals( - EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.ChooseAmount), - awaitItem(), - ) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `onClickContinue should emit create CJIT for spending amount within limits`() = test { - whenever(blocktankRepo.maxCjitAmountSats()).thenReturn(Result.success(100_000u)) - + fun `onClickContinue should emit transfer unavailable for spending above inbound`() = test { sut.editInvoiceEffect.test { sut.onClickContinue( source = ReceiveLiquiditySource.SPENDING, amountSats = 10_000u, - isGeoBlocked = false, ) assertEquals( EditInvoiceScreenEffects.ApplyReceiveLiquidityAction( - ReceiveAdditionalLiquidityAction.CreateCjit(10_000u) + ReceiveAdditionalLiquidityAction.TransferUnavailable ), awaitItem(), ) @@ -90,16 +62,15 @@ class EditInvoiceVMTest : BaseUnitTest() { } @Test - fun `onClickContinue should emit geo blocked without fetching CJIT limits`() = test { + fun `onClickContinue should emit none for spending within inbound`() = test { sut.editInvoiceEffect.test { sut.onClickContinue( source = ReceiveLiquiditySource.SPENDING, - amountSats = 10_000u, - isGeoBlocked = true, + amountSats = 500u, ) assertEquals( - EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.GeoBlocked), + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None), awaitItem(), ) cancelAndIgnoreRemainingEvents() diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSwitchTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSwitchTest.kt index c6d7bdcbbe..1472131bf3 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSwitchTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveAutoTabSwitchTest.kt @@ -26,16 +26,6 @@ class ReceiveAutoTabSwitchTest { assertFalse(decide(canCreateLightningInvoice = false)) } - @Test - fun `does not switch when cjit invoice exists`() { - assertFalse(decide(cjitInvoice = "lnbcrt1cjit")) - } - - @Test - fun `switches when cjit invoice is empty`() { - assertTrue(decide(cjitInvoice = "")) - } - @Test fun `does not switch when an initial tab was requested`() { assertFalse(decide(initialTab = ReceiveTab.SPENDING)) @@ -45,13 +35,11 @@ class ReceiveAutoTabSwitchTest { selectedTab: ReceiveTab = ReceiveTab.SAVINGS, hasUserSelectedTab: Boolean = false, canCreateLightningInvoice: Boolean = true, - cjitInvoice: String? = null, initialTab: ReceiveTab? = null, ) = shouldAutoSwitchToAuto( selectedTab = selectedTab, hasUserSelectedTab = hasUserSelectedTab, canCreateLightningInvoice = canCreateLightningInvoice, - cjitInvoice = cjitInvoice, initialTab = initialTab, ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceEditStateTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceEditStateTest.kt index 383596d685..09d1bfe6b5 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceEditStateTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceEditStateTest.kt @@ -70,102 +70,4 @@ class ReceiveInvoiceEditStateTest { assertEquals(sourceTab, state.initialTab(hardwareWalletId = null)) } } - - @Test - fun `receive CJIT session keeps invoice when edit is cancelled`() { - val state = ReceiveCjitSessionState() - val entry = cjitEntryDetails(invoice = "first") - - state.onCjitCreated(entry) - state.onCjitConfirmed("first") - - assertEquals("first", state.cjitInvoice) - assertEquals(entry, state.entryDetails) - } - - @Test - fun `receive CJIT session clears stale invoice when edit is applied`() { - val state = ReceiveCjitSessionState() - val entry = cjitEntryDetails(invoice = "first") - - state.onCjitCreated(entry) - state.onCjitConfirmed("first") - state.clear() - - assertNull(state.cjitInvoice) - assertNull(state.entryDetails) - } - - @Test - fun `receive CJIT session keeps old invoice until fresh CJIT is confirmed`() { - val state = ReceiveCjitSessionState() - val first = cjitEntryDetails(invoice = "first") - val second = cjitEntryDetails(invoice = "second") - - state.onCjitCreated(first) - state.onCjitConfirmed("first") - state.onCjitCreated(second) - - assertEquals("first", state.cjitInvoice) - assertEquals(second, state.entryDetails) - - state.onCjitConfirmed("second") - - assertEquals("second", state.cjitInvoice) - assertEquals(second, state.entryDetails) - } - - @Test - fun `receive CJIT session exposes confirmed fresh invoice`() { - val state = ReceiveCjitSessionState() - val entry = cjitEntryDetails(invoice = "fresh") - - state.onCjitCreated(entry) - state.onCjitConfirmed("fresh") - - assertEquals("fresh", state.cjitInvoice) - assertEquals(entry, state.entryDetails) - } - - @Test - fun `receive CJIT session matches confirmed invoice amount`() { - val state = ReceiveCjitSessionState() - val entry = cjitEntryDetails(invoice = "fresh", receiveAmountSats = 1_000) - - state.onCjitCreated(entry) - - assertFalse(state.hasConfirmedInvoiceForAmount(1_000uL)) - - state.onCjitConfirmed("fresh") - - assertTrue(state.hasConfirmedInvoiceForAmount(1_000uL)) - assertFalse(state.hasConfirmedInvoiceForAmount(2_000uL)) - assertFalse(state.hasConfirmedInvoiceForAmount(null)) - } - - @Test - fun `receive CJIT session matches confirmed amount when pending quote differs`() { - val state = ReceiveCjitSessionState() - val first = cjitEntryDetails(invoice = "first", receiveAmountSats = 1_000) - val second = cjitEntryDetails(invoice = "second", receiveAmountSats = 2_000) - - state.onCjitCreated(first) - state.onCjitConfirmed("first") - state.onCjitCreated(second) - - assertTrue(state.hasConfirmedInvoiceForAmount(1_000uL)) - assertFalse(state.hasConfirmedInvoiceForAmount(2_000uL)) - } - - private fun cjitEntryDetails( - invoice: String, - receiveAmountSats: Long = 1_000, - ) = CjitEntryDetails( - networkFeeSat = 1, - serviceFeeSat = 1, - channelSizeSat = 10_000, - feeSat = 2, - receiveAmountSats = receiveAmountSats, - invoice = invoice, - ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt index 8ec9f148ea..d13389dbd6 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt @@ -10,7 +10,7 @@ class ReceiveInvoiceUtilsTest { for (tab in listOf(ReceiveTab.AUTO, ReceiveTab.SPENDING)) { assertEquals( ReceiveInvoiceDisplayMode.LOADING, - getReceiveInvoiceDisplayMode(tab, null, hasInvoiceError = false, isUpdating = true, showDetails = true), + getReceiveInvoiceDisplayMode(tab, hasInvoiceError = false, isUpdating = true, showDetails = true), ) } } @@ -21,7 +21,6 @@ class ReceiveInvoiceUtilsTest { ReceiveInvoiceDisplayMode.ERROR, getReceiveInvoiceDisplayMode( ReceiveTab.AUTO, - null, hasInvoiceError = true, isUpdating = false, showDetails = true, @@ -31,21 +30,6 @@ class ReceiveInvoiceUtilsTest { ReceiveInvoiceDisplayMode.LOADING, getReceiveInvoiceDisplayMode( ReceiveTab.AUTO, - null, - hasInvoiceError = true, - isUpdating = true, - showDetails = true, - ), - ) - } - - @Test - fun `confirmed CJIT Details remain available during receive updates`() { - assertEquals( - ReceiveInvoiceDisplayMode.DETAILS, - getReceiveInvoiceDisplayMode( - ReceiveTab.SPENDING, - "confirmed-cjit", hasInvoiceError = true, isUpdating = true, showDetails = true, @@ -62,7 +46,6 @@ class ReceiveInvoiceUtilsTest { tab = tab, bip21 = "bitcoin:address?lightning=old-invoice", bolt11 = "old-invoice", - cjitInvoice = null, isNodeRunning = true, onchainAddress = "address", isReceiveInvoiceUpdating = true, @@ -71,29 +54,12 @@ class ReceiveInvoiceUtilsTest { } } - @Test - fun `pending receive updates preserve a confirmed CJIT invoice`() { - assertEquals( - "confirmed-cjit", - getInvoiceForTab( - tab = ReceiveTab.SPENDING, - bip21 = "bitcoin:address", - bolt11 = "old-invoice", - cjitInvoice = "confirmed-cjit", - isNodeRunning = true, - onchainAddress = "address", - isReceiveInvoiceUpdating = true, - ), - ) - } - @Test fun `getInvoiceForTab HARDWARE returns only the hardware address`() { val result = getInvoiceForTab( tab = ReceiveTab.HARDWARE, bip21 = "bitcoin:software?lightning=lnbc1software", bolt11 = "lnbc1software", - cjitInvoice = null, isNodeRunning = true, onchainAddress = "bc1qsoftware", hardwareAddress = "bc1qhardware", @@ -108,7 +74,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.HARDWARE, bip21 = "bitcoin:software", bolt11 = "", - cjitInvoice = null, isNodeRunning = true, onchainAddress = "bc1qsoftware", hardwareAddress = "bc1qhardware", @@ -125,7 +90,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.HARDWARE, bip21 = "bitcoin:bc1qsoftware", bolt11 = "", - cjitInvoice = null, isNodeRunning = true, onchainAddress = "bc1qsoftware", hardwareAddress = "bc1qhardware", @@ -137,7 +101,6 @@ class ReceiveInvoiceUtilsTest { private val testAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" private val testBolt11 = "lnbc1500n1pn2s39xpp5wyxw0e9fvvf..." - private val testCjitInvoice = "lnbc2000n1pn2s39xpp5zyxw0e9fvvf..." @Test fun `getInvoiceForTab SAVINGS returns BIP21 without lightning parameter`() { @@ -147,7 +110,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.SAVINGS, bip21 = bip21WithAmount, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, onchainAddress = testAddress ) @@ -163,7 +125,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.SAVINGS, bip21 = bip21, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, onchainAddress = testAddress ) @@ -179,7 +140,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.SAVINGS, bip21 = bip21WithoutLightning, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, onchainAddress = testAddress ) @@ -193,7 +153,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.SAVINGS, bip21 = "", bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, onchainAddress = testAddress ) @@ -209,7 +168,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.SAVINGS, bip21 = bip21OnlyLightning, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, onchainAddress = testAddress ) @@ -225,7 +183,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.AUTO, bip21 = bip21, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, onchainAddress = testAddress ) @@ -241,7 +198,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.AUTO, bip21 = bip21, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = false, onchainAddress = testAddress ) @@ -257,7 +213,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.AUTO, bip21 = bip21WithoutLightning, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, onchainAddress = testAddress ) @@ -273,7 +228,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.AUTO, bip21 = bip21WithoutLightning, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = false, onchainAddress = testAddress ) @@ -289,7 +243,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.AUTO, bip21 = bip21, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, canCreateLightningInvoice = false, onchainAddress = testAddress @@ -306,7 +259,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.AUTO, bip21 = bip21LightningFirst, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, onchainAddress = testAddress ) @@ -315,30 +267,13 @@ class ReceiveInvoiceUtilsTest { } @Test - fun `getInvoiceForTab SPENDING returns CJIT invoice when available and node running`() { + fun `getInvoiceForTab SPENDING returns bolt11 when a lightning invoice can be created`() { val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" val result = getInvoiceForTab( tab = ReceiveTab.SPENDING, bip21 = bip21, bolt11 = testBolt11, - cjitInvoice = testCjitInvoice, - isNodeRunning = true, - onchainAddress = testAddress - ) - - assertEquals(testCjitInvoice, result) - } - - @Test - fun `getInvoiceForTab SPENDING returns bolt11 when CJIT unavailable and lightning invoice can be created`() { - val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" - - val result = getInvoiceForTab( - tab = ReceiveTab.SPENDING, - bip21 = bip21, - bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, onchainAddress = testAddress ) @@ -354,7 +289,6 @@ class ReceiveInvoiceUtilsTest { tab = ReceiveTab.SPENDING, bip21 = bip21, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = true, canCreateLightningInvoice = false, onchainAddress = testAddress @@ -364,30 +298,13 @@ class ReceiveInvoiceUtilsTest { } @Test - fun `getInvoiceForTab SPENDING returns empty when node not running even with CJIT`() { - val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" - - val result = getInvoiceForTab( - tab = ReceiveTab.SPENDING, - bip21 = bip21, - bolt11 = testBolt11, - cjitInvoice = testCjitInvoice, - isNodeRunning = false, - onchainAddress = testAddress - ) - - assertEquals("", result) - } - - @Test - fun `getInvoiceForTab SPENDING returns empty when node not running and no CJIT`() { + fun `getInvoiceForTab SPENDING returns empty when node not running`() { val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" val result = getInvoiceForTab( tab = ReceiveTab.SPENDING, bip21 = bip21, bolt11 = testBolt11, - cjitInvoice = null, isNodeRunning = false, onchainAddress = testAddress ) diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt index d707a61f02..b88efc486e 100644 --- a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt @@ -20,7 +20,7 @@ import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.WidgetsRepo import to.bitkit.test.BaseUnitTest -import to.bitkit.utils.ServiceError +import to.bitkit.utils.AppError import java.math.BigDecimal import java.util.Locale import kotlin.test.assertEquals @@ -67,7 +67,7 @@ class CalculatorViewModelTest : BaseUnitTest() { ) } whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { - fiatToSatsValue ?: throw ServiceError.CurrencyRateUnavailable() + fiatToSatsValue ?: throw AppError("Currency rate unavailable") } whenever { widgetsRepo.updateCalculatorValues(any()) }.thenAnswer { updateCalculatorValuesCalls++ diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index c816598e0c..bd2af98955 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -716,7 +716,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { migrationService.needsPostMigrationSync() }.thenReturn(false) whenever { migrationService.isMigrationChecked() }.thenReturn(true) whenever { widgetsRepo.refreshEnabledWidgets() }.thenReturn(Unit) - whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit) whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.adoptedSourceLost).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.contactImportFailure).thenReturn(pubkyContactImportFailure) @@ -12880,34 +12879,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.successSendUiState.value.paymentHashOrTxId) } - @Test - fun `retained transfer Pending opens original funding recovery without changing send inputs`() = test { - val original = OnchainSendAttempt( - walletId = WalletScope.default, attemptId = "original-funding", requestId = null, - orderId = "order-1", address = "bcrt1qoriginalorder", amountSats = 1_000uL, - isMaxAmount = false, feeRateSatsPerVByte = 2uL, isTransfer = true, - channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Rejected, - txid = "ab".repeat(32), refusalReason = "declined", - ) - setSendState(SendUiState(address = "bcrt1qother", amount = 9_000u)) - sut.showPendingTransfer(original) - advanceUntilIdle() - assertEquals( - Sheet.Send( - SendRoute.Pending( - paymentHash = requireNotNull(original.txid), - amount = 1_000, - observeResolution = false, - isOnchain = true, - walletId = original.walletId, - refusalReason = original.refusalReason, - ) - ), - sut.currentSheet.value, - ) - assertEquals("bcrt1qother", sut.sendUiState.value.address) - } - @Test fun `retry fee approval works without USD rate and keeps the Bitcoin fee warning`() = test { val original = OnchainSendAttempt( @@ -12945,60 +12916,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `recovery denies paid expired or changed original Blocktank order before broadcast`() = test { - val order = to.bitkit.ext.mockOrder().copy( - orderExpiresAt = (Clock.System.now() + 60.seconds).toString(), - ) - val original = OnchainSendAttempt( - walletId = WalletScope.default, - attemptId = "original-order-attempt", - transferContext = to.bitkit.repositories.OnchainTransferContext( - 1_200uL, - 100_000uL, - order.clientBalanceSat, - order.feeSat, - ), - requestId = null, - orderId = order.id, - address = requireNotNull(order.payment?.onchain?.address), - amountSats = order.feeSat, - isMaxAmount = false, - feeRateSatsPerVByte = 1uL, - isTransfer = true, - channelId = null, - tags = emptyList(), - evidence = OnchainSendEvidence.Unknown, - ) - var dispatched = 0 - whenever( - lightningRepo.retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any(), any()) - ).doSuspendableAnswer { invocation -> - val authorize = invocation.getArgument Unit>(5) - runCatching { - authorize(original) - dispatched += 1 - OnchainSendOutcome.Accepted("ef".repeat(32)) - } - } - whenever(lightningRepo.isTransferBlocked()).thenReturn(false) - val invalidOrders = listOf( - order.copy(state2 = com.synonym.bitkitcore.BtOrderState2.PAID), - order.copy(orderExpiresAt = (Clock.System.now() - 1.seconds).toString()), - order.copy(feeSat = order.feeSat + 1uL), - order.copy(clientBalanceSat = order.clientBalanceSat + 1uL), - ) - for (invalidOrder in invalidOrders) { - whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(invalidOrder))) - assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isFailure) - } - assertEquals(0, dispatched) - whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) - assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isSuccess) - assertEquals(1, dispatched) - } - - @Test - fun `recovery of a Blocktank order broadcasts nothing while transfers are unavailable`() = test { + fun `recovery of a Blocktank order shows the sheet and broadcasts nothing`() = test { val order = to.bitkit.ext.mockOrder().copy( orderExpiresAt = (Clock.System.now() + 60.seconds).toString(), ) @@ -13022,9 +12940,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = emptyList(), evidence = OnchainSendEvidence.Unknown, ) - whenever(lightningRepo.isTransferBlocked()).thenReturn(true) - whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) - assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isFailure) verify(lightningRepo, never()).retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any(), any()) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index f915475380..9c7e581058 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -2,115 +2,40 @@ package to.bitkit.viewmodels import android.content.Context import app.cash.turbine.test -import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.BoltzPairInfo import com.synonym.bitkitcore.BoltzSwapEvent -import com.synonym.bitkitcore.BroadcastException -import com.synonym.bitkitcore.ChannelLiquidityOptions -import com.synonym.bitkitcore.FeeRates -import com.synonym.bitkitcore.IBtEstimateFeeResponse2 -import com.synonym.bitkitcore.IBtInfo -import com.synonym.bitkitcore.IBtInfoOptions -import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.ReverseSwapResponse -import com.synonym.bitkitcore.TrezorException -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.BalanceDetails -import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeStatus -import org.lightningdevkit.ldknode.OutPoint import org.lightningdevkit.ldknode.PaymentFailureReason -import org.lightningdevkit.ldknode.SpendableUtxo import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.atLeastOnce -import org.mockito.kotlin.doSuspendableAnswer -import org.mockito.kotlin.eq -import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R -import to.bitkit.data.AppCacheData -import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.env.Defaults -import to.bitkit.models.AddressModel -import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BalanceState -import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.ConvertedAmount -import to.bitkit.models.HwConnectedDevice -import to.bitkit.models.HwFundingAccount -import to.bitkit.models.HwFundingAddressType -import to.bitkit.models.HwFundingBroadcastResult -import to.bitkit.models.HwFundingSignedTx -import to.bitkit.models.HwFundingTransaction -import to.bitkit.models.HwWallet -import to.bitkit.models.HwWalletVendor -import to.bitkit.models.PrimaryDisplay -import to.bitkit.models.Toast -import to.bitkit.models.TransactionSpeed -import to.bitkit.models.TransferType -import to.bitkit.models.TransportType -import to.bitkit.models.WalletScope -import to.bitkit.models.formatToModernDisplay -import to.bitkit.models.safe -import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.BlocktankState -import to.bitkit.repositories.CurrencyRepo -import to.bitkit.repositories.CurrencyState -import to.bitkit.repositories.HwPassphraseMismatchError -import to.bitkit.repositories.HwPassphraseRequiredError -import to.bitkit.repositories.HwWalletMismatchError -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState -import to.bitkit.repositories.OnchainSendAttempt -import to.bitkit.repositories.OnchainSendEvidence -import to.bitkit.repositories.OnchainSendOutcome -import to.bitkit.repositories.OnchainTransferContext import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService import to.bitkit.test.BaseUnitTest -import to.bitkit.ui.screens.transfer.previewBtOrder -import to.bitkit.ui.screens.transfer.previewSpendingState -import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError -import java.math.BigDecimal import kotlin.math.roundToLong import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @@ -121,3228 +46,35 @@ class TransferViewModelTest : BaseUnitTest() { private val context = mock() private val lightningRepo = mock() - private val blocktankRepo = mock() - private val hwWalletRepo = mock() private val walletRepo = mock() - private val settingsStore = mock() - private val cacheStore = mock() private val transferRepo = mock() private val clock = mock() private val boltzService = mock() - private val currencyRepo = mock() private val balanceState = MutableStateFlow(BalanceState()) - private val blocktankState = MutableStateFlow(BlocktankState()) - private val currencyState = MutableStateFlow(CurrencyState()) private val boltzEvents = MutableSharedFlow(extraBufferCapacity = 8) private val nodeEvents = MutableSharedFlow(extraBufferCapacity = 8) - private val feeResponse = mock() @Before fun setUp() { - whenever { hwWalletRepo.reconnectTimeout(any()) }.thenReturn(30.seconds) - whenever(feeResponse.feeSat).thenReturn(LSP_FEE) - whenever(feeResponse.networkFeeSat).thenReturn(NETWORK_FEE) - whenever(feeResponse.serviceFeeSat).thenReturn(SERVICE_FEE) whenever(context.getString(any())).thenReturn("") - whenever(walletRepo.getOnchainAddress()).thenReturn(WALLET_ADDRESS) - whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) - whenever(cacheStore.data).thenReturn(MutableStateFlow(AppCacheData())) - whenever { transferRepo.persistAcceptedFunding(any(), any(), anyOrNull()) }.doSuspendableAnswer { - val originalOrder = it.getArgument(0) - cacheStore.addPaidOrder(originalOrder.id, it.getArgument(1)) - Result.success(Unit) - } - whenever { transferRepo.findLspOrderIdByFundingTxId(any()) }.thenReturn(Result.success(null)) - whenever { - transferRepo.createTransfer( - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - ) - } - .thenReturn(Result.success("transfer-id")) - whenever { hwWalletRepo.needsPassphrase(any()) }.thenReturn(false) - whenever { lightningRepo.isTransferBlocked() }.thenReturn(false) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) whenever(walletRepo.balanceState).thenReturn(balanceState) - whenever(blocktankRepo.blocktankState).thenReturn(blocktankState) - whenever(currencyRepo.currencyState).thenReturn(currencyState) - whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn(Result.failure(AppError("no rate"))) whenever(boltzService.events).thenReturn(boltzEvents) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(boltzService.isSwapSupported).thenReturn(true) whenever { boltzService.isSwapEnabled() }.thenReturn(true) - // Default: no mining-fee reserve so existing limit tests keep their balances. - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever { lightningRepo.getFeeRates() }.thenReturn(Result.success(FeeRates(fast = 2u, mid = 2u, slow = 2u))) - whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) } - .thenReturn(Result.success(2uL)) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(listOf(stubUtxo(ON_CHAIN_BALANCE)))) - whenever { walletRepo.getAddresses(any(), any(), any(), any()) }.thenReturn( - Result.success(listOf(AddressModel(address = WALLET_ADDRESS, index = 0, path = "m/84"))), - ) - - sut = TransferViewModel( - context = context, - lightningRepo = lightningRepo, - blocktankRepo = blocktankRepo, - hwWalletRepo = hwWalletRepo, - walletRepo = walletRepo, - settingsStore = settingsStore, - cacheStore = cacheStore, - transferRepo = transferRepo, - clock = clock, - boltzService = boltzService, - currencyRepo = currencyRepo, - ) - } - - @Test - fun `updateLimits caps spending max at LSP max client balance when on-chain balance exceeds it`() = test { - stubSpendableBalances(ON_CHAIN_BALANCE) - blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) - // The LSP reports no room for receiving liquidity (maxLspBalanceSat = 0) because the - // client balance saturates the channel — the regression this guards against. - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - - sut.updateLimits() - advanceUntilIdle() - - val state = sut.spendingUiState.value - assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), state.maxAllowedToSend) - assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), state.balanceAfterFee) - assertEquals((OPTION_MAX_CLIENT_BALANCE.toDouble() * 0.25).roundToLong(), state.quarterAmount) - - // The order fee must be estimated against the clamped client balance, not the full balance. - verify(blocktankRepo).estimateOrderFee(eq(LSP_MAX_CLIENT_BALANCE), any(), any()) - } - - @Test - fun `updateLimits uses the full balance when LSP info is unavailable`() = test { - stubSpendableBalances(ON_CHAIN_BALANCE) - blocktankState.value = BlocktankState(info = null) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - - sut.updateLimits() - advanceUntilIdle() - - assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), sut.spendingUiState.value.maxAllowedToSend) - // Without an LSP cap the order fee is estimated against the balance after the LSP fee. - verify(blocktankRepo).estimateOrderFee(eq(ON_CHAIN_BALANCE - LSP_FEE), any(), any()) - } - - @Test - fun `updateLimits sets max to zero when LSP reports zero client balance`() = test { - stubSpendableBalances(ON_CHAIN_BALANCE) - blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = 0uL))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - - sut.updateLimits() - advanceUntilIdle() - - assertEquals(0L, sut.spendingUiState.value.maxAllowedToSend) - } - - @Test - fun `updateLimits reserves fast mining fee before sizing max transfer`() = test { - // multi_address_2-style tight balance: without this reserve, feeSat + miningFee > spendable. - val spendable = 100_000uL - val miningFee = 1_058uL - val availableAfterMining = spendable - miningFee - // maxSendOnchainSats is already fee-adjusted for send UI — limits must ignore it and - // reserve exactly one fast fee from raw spendable (not double-subtract). - balanceState.value = BalanceState(maxSendOnchainSats = spendable - miningFee) - stubSpendableBalances(spendable) - blocktankState.value = BlocktankState(info = null) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(miningFee)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - - sut.updateLimits() - advanceUntilIdle() - - val expectedMax = (availableAfterMining - LSP_FEE).toLong() - assertEquals(expectedMax, sut.spendingUiState.value.maxAllowedToSend) - verify(lightningRepo).estimateSendAllFee( - address = anyOrNull(), - speed = eq(TransactionSpeed.Fast), - feeRates = anyOrNull(), - ) - verify(blocktankRepo).estimateOrderFee(eq(availableAfterMining), any(), any()) - } - - @Test - fun `updateLimits caps spending max at the balance the LSP fee was quoted for`() = test { - // Real-world numbers from issue #899: the LSP service fee grows with the client balance, so - // the second (cheaper) quote must not be used to derive a larger balance than it priced. - val spendable = 265_904uL - val miningFee = 178uL - val availableAmount = spendable - miningFee - val initialLspFees = 4_165uL - val balanceAfterLspFee = availableAmount - initialLspFees - val finalLspFees = 4_128uL - val initialFeeResponse = stubFeeResponse(initialLspFees) - val finalFeeResponse = stubFeeResponse(finalLspFees) - stubSpendableBalances(spendable) - blocktankState.value = BlocktankState(info = null) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(miningFee)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) - whenever(blocktankRepo.estimateOrderFee(eq(availableAmount), any(), any())) - .thenReturn(Result.success(initialFeeResponse)) - whenever(blocktankRepo.estimateOrderFee(eq(balanceAfterLspFee), any(), any())) - .thenReturn(Result.success(finalFeeResponse)) - - sut.updateLimits() - advanceUntilIdle() - - val maxAllowedToSend = sut.spendingUiState.value.maxAllowedToSend - assertEquals(balanceAfterLspFee.toLong(), maxAllowedToSend) - // The order the user can build at this max must stay within what they can actually pay. - assertTrue(maxAllowedToSend.toULong() + finalLspFees <= availableAmount) - } - - @Test - fun `updateLimits settles the spending max when the LSP fee falls as the client balance rises`() = test { - // Live quotes from the staging LSP, which charges the LSP side harder than the client side, - // so the second quote is dearer than the first and no ordering assumption can hold. - val spendable = 266_656uL - val miningFee = 178uL - val availableAmount = spendable - miningFee // 266_478 - val quotes = mapOf( - 266_478uL to 1_798uL, // f(A) -> balanceAfterLspFee = 264_680 - 264_680uL to 1_800uL, // f(C) -> first candidate is unaffordable - 264_678uL to 1_801uL, // round 1 -> still one sat over - 264_677uL to 1_801uL, // round 2 -> affordable - ) - stubSpendableBalances(spendable) - blocktankState.value = BlocktankState(info = null) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(miningFee)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) - val responses = quotes.mapValues { (_, fee) -> stubFeeResponse(fee) } - responses.forEach { (balance, response) -> - whenever(blocktankRepo.estimateOrderFee(eq(balance), any(), any())) - .thenReturn(Result.success(response)) - } - - sut.updateLimits() - advanceUntilIdle() - - val maxAllowedToSend = sut.spendingUiState.value.maxAllowedToSend - assertEquals(264_677L, maxAllowedToSend) - // The settled max must fund its own order rather than merely undercut the first quote. - assertTrue(maxAllowedToSend.toULong() + quotes.getValue(maxAllowedToSend.toULong()) <= availableAmount) - } - - @Test - fun `updateLimits re-quotes against the channel split the order will actually use`() = test { - // Order creation recomputes the LSP balance from the chosen amount, so a re-quote priced - // against the earlier balance would verify an order that is never created. - val maxChannel = 1_403_872uL - val spendable = 266_656uL - val miningFee = 178uL - val availableAmount = spendable - miningFee // 266_478 - val quotes = mapOf(266_478uL to 1_798uL, 264_680uL to 1_800uL, 264_678uL to 1_801uL, 264_677uL to 1_801uL) - val responses = quotes.mapValues { (_, fee) -> stubFeeResponse(fee) } - stubSpendableBalances(spendable) - blocktankState.value = BlocktankState(info = null) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(miningFee)) - responses.forEach { (balance, response) -> - // each client balance gets its own LSP side, mirroring maxChannelSize - clientBalance - whenever(blocktankRepo.calculateLiquidityOptions(eq(balance))).thenReturn( - Result.success( - ChannelLiquidityOptions( - defaultLspBalanceSat = maxChannel - balance, - minLspBalanceSat = maxChannel - balance, - maxLspBalanceSat = maxChannel - balance, - maxClientBalanceSat = spendable, - ) - ) - ) - whenever(blocktankRepo.estimateOrderFee(eq(balance), any(), any())) - .thenReturn(Result.success(response)) - } - - sut.updateLimits() - advanceUntilIdle() - - assertEquals(264_677L, sut.spendingUiState.value.maxAllowedToSend) - // the re-quote must price 264_678 against its own split, not the one taken at 264_680 - verify(blocktankRepo).estimateOrderFee(eq(264_678uL), eq(maxChannel - 264_678uL), any()) - verify(blocktankRepo, never()).estimateOrderFee(eq(264_678uL), eq(maxChannel - 264_680uL), any()) - } - - @Test - fun `onConfirmAmount refuses to quote an order the balance cannot fund`() = test { - val amount = 260_000uL - val budget = 265_000uL - val response = stubFeeResponse(6_000uL) - stubSpendableBalances(budget) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - sut.updateLimits() - advanceUntilIdle() - - sut.transferEffects.test { - sut.onConfirmAmount(amount.toLong()) - advanceUntilIdle() - - assertIs(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - assertEquals(0uL, sut.spendingUiState.value.feeSat) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - assertFalse(sut.spendingUiState.value.isLoading) - } - - @Test - fun `onConfirmAmount quotes the order when it fits the funding budget`() = test { - val amount = 260_000uL - val response = stubFeeResponse(1_000uL) - stubSpendableBalances(265_000uL) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - sut.updateLimits() - advanceUntilIdle() - - sut.transferEffects.test { - sut.onConfirmAmount(amount.toLong()) - advanceUntilIdle() - - assertIs(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - - val quote = sut.spendingUiState.value - assertEquals(amount, quote.clientBalanceSat) - assertEquals(LSP_BALANCE, quote.lspBalanceSat) - assertEquals(amount + 1_000uL, quote.feeSat) - assertNull(sut.spendingUiState.value.order) - verify(blocktankRepo).estimateOrderFee(eq(amount), eq(LSP_BALANCE), any()) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onConfirmAmount proceeds when the on-chain balance cannot be read`() = test { - val amount = 260_000uL - whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("node unavailable"))) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - sut.updateLimits() - advanceUntilIdle() - assertNull(sut.spendingUiState.value.fundingBudgetSats) - - sut.onConfirmAmount(amount.toLong()) - advanceUntilIdle() - - assertEquals(amount, sut.spendingUiState.value.clientBalanceSat) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onConfirmAmount stays on the amount step when the quote fails`() = test { - val amount = 260_000uL - val response = stubFeeResponse(1_000uL) - stubSpendableBalances(265_000uL) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - sut.updateLimits() - advanceUntilIdle() - - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())) - .thenReturn(Result.failure(AppError("lsp unreachable"))) - - sut.transferEffects.test { - sut.onConfirmAmount(amount.toLong()) - advanceUntilIdle() - - assertIs(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - - assertEquals(0uL, sut.spendingUiState.value.feeSat) - assertFalse(sut.spendingUiState.value.isLoading) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `updateLimits keeps the last candidate when a re-quote fails`() = test { - val spendable = 266_656uL - val miningFee = 178uL - val availableAmount = spendable - miningFee // 266_478 - stubSpendableBalances(spendable) - blocktankState.value = BlocktankState(info = null) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(miningFee)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) - val first = stubFeeResponse(1_798uL) - val second = stubFeeResponse(1_800uL) - whenever(blocktankRepo.estimateOrderFee(eq(availableAmount), any(), any())) - .thenReturn(Result.success(first)) - whenever(blocktankRepo.estimateOrderFee(eq(264_680uL), any(), any())) - .thenReturn(Result.success(second)) - whenever(blocktankRepo.estimateOrderFee(eq(264_678uL), any(), any())) - .thenReturn(Result.failure(AppError("lsp unreachable"))) - - sut.updateLimits() - advanceUntilIdle() - - // the step-down candidate is still published rather than the unaffordable quoted balance - assertEquals(264_678L, sut.spendingUiState.value.maxAllowedToSend) - } - - @Test - fun `updateLimits falls back to the shortfall balance when rounds are exhausted`() = test { - val spendable = 266_656uL - val miningFee = 178uL - val availableAmount = spendable - miningFee // 266_478 - // the fee rises as fast as the balance steps down, so no candidate ever becomes affordable - val quotes = mapOf( - 266_478uL to 1_800uL, // f(A) -> balanceAfterLspFee = 264_678 - 264_678uL to 2_000uL, // f(C) -> 266_678, over budget - 264_478uL to 2_200uL, // round 1 -> 266_678, still over - 264_278uL to 2_400uL, // round 2 -> 266_678, rounds exhausted - ) - stubSpendableBalances(spendable) - blocktankState.value = BlocktankState(info = null) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(miningFee)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) - val responses = quotes.mapValues { (_, fee) -> stubFeeResponse(fee) } - responses.forEach { (balance, response) -> - whenever(blocktankRepo.estimateOrderFee(eq(balance), any(), any())) - .thenReturn(Result.success(response)) - } - - sut.updateLimits() - advanceUntilIdle() - - // the exhausted loop advertises availableAmount minus the last quote, not the last candidate - assertEquals((availableAmount - 2_400uL).toLong(), sut.spendingUiState.value.maxAllowedToSend) - } - - @Test - fun `updateLimits uses percent fallback when fast mining fee estimate fails`() = test { - val spendable = 100_000uL - val fallbackMiningFee = (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() - val availableAfterMining = spendable - fallbackMiningFee - stubSpendableBalances(spendable) - blocktankState.value = BlocktankState(info = null) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.failure(AppError("fee unavailable"))) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - - sut.updateLimits() - advanceUntilIdle() - - assertEquals((availableAfterMining - LSP_FEE).toLong(), sut.spendingUiState.value.maxAllowedToSend) - verify(blocktankRepo).estimateOrderFee(eq(availableAfterMining), any(), any()) - } - - @Test - fun `updateHwLimits sources the available amount from the hardware account balance`() = test { - // walletRepo balance stays 0 to prove the limit comes from the hardware account, not on-chain savings. - blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) - whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)) - .thenReturn( - Result.success( - HwFundingAccount.Trezor( - xpub = XPUB, - addressType = HwFundingAddressType.NATIVE_SEGWIT, - balanceSats = ON_CHAIN_BALANCE, - ), - ), - ) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - - sut.updateHwLimits(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), sut.spendingUiState.value.maxAllowedToSend) - } - - @Test - fun `onConfirmAmount funds a hardware transfer from the device balance not the on-chain wallet`() = test { - // Regression: the funding check must not read on-chain savings here, or every hardware - // transfer is rejected because those funds live on the device. - val amount = 100_000uL - stubSpendableBalances(0uL) // empty on-chain wallet, as in the hardware e2e - blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) - whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)).thenReturn( - Result.success( - HwFundingAccount.Trezor( - xpub = XPUB, - addressType = HwFundingAddressType.NATIVE_SEGWIT, - balanceSats = ON_CHAIN_BALANCE, - ), - ), - ) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - sut.updateHwLimits(HARDWARE_WALLET_ID) - advanceUntilIdle() - - sut.onConfirmAmount(amount.toLong()) - advanceUntilIdle() - - assertEquals(amount, sut.spendingUiState.value.clientBalanceSat) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `updateHwLimits reserves fallback fee when fee rate lookup fails`() = test { - blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) - whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)) - .thenReturn( - Result.success( - HwFundingAccount.Trezor( - xpub = XPUB, - addressType = HwFundingAddressType.NATIVE_SEGWIT, - balanceSats = ON_CHAIN_BALANCE, - ), - ), - ) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.failure(AppError("fee unavailable"))) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - - sut.updateHwLimits(HARDWARE_WALLET_ID) - advanceUntilIdle() - - val fallbackReserve = (ON_CHAIN_BALANCE.toDouble() * Defaults.fallbackFeePercent).toULong() - verify(blocktankRepo).estimateOrderFee(eq(ON_CHAIN_BALANCE.safe() - fallbackReserve.safe()), any(), any()) - } - - @Test - fun `updateHwFundingFeeEstimate uses native segwit independently of the receive preference`() = test { - whenever(walletRepo.getOnchainAddress()).thenReturn("bcrt1ptaproot") - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - quoteOrder(order) - - sut.updateHwFundingFeeEstimate(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertEquals(MINING_FEE, sut.spendingUiState.value.hwMiningFeeSats) - verify(walletRepo).getAddresses(0, false, 1, AddressType.P2WPKH) - verify(hwWalletRepo).composeFundingTransaction( - eq(HARDWARE_WALLET_ID), - eq(WALLET_ADDRESS), - eq(order.feeSat), - eq(FEE_RATE), - ) - verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `updateHwFundingFeeEstimate ignores superseded estimate`() = test { - val raisedCapacity = LSP_BALANCE * 2u - val staleCompose = CompletableDeferred>() - val fundingB = HwFundingTransaction( - psbt = "psbt-b", - miningFeeSats = 999uL, - feeRate = FEE_RATE.toFloat(), - totalSpent = OPTION_MAX_CLIENT_BALANCE + LSP_FEE + 999uL, - satsPerVByte = FEE_RATE, - ) - val staleFunding = HwFundingTransaction( - psbt = "psbt-a", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = OPTION_MAX_CLIENT_BALANCE + LSP_FEE + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).doSuspendableAnswer { - if (sut.spendingUiState.value.isAdvanced) { - Result.success(fundingB) - } else { - staleCompose.await() - } - } - - sut.onConfirmAmount(OPTION_MAX_CLIENT_BALANCE.toLong()) - advanceUntilIdle() - - sut.updateHwFundingFeeEstimate(HARDWARE_WALLET_ID) - runCurrent() - - sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) - advanceUntilIdle() - - sut.updateHwFundingFeeEstimate(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats) - - staleCompose.complete(Result.success(staleFunding)) - advanceUntilIdle() - - assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onReceivingAmountChange discards a slower quote for an amount already left`() = test { - val staleAmount = 900_000uL - val freshAmount = 300_000uL - val staleQuote = CompletableDeferred>() - val staleResponse = stubFeeResponse(6_000uL) - val freshResponse = stubFeeResponse(1_000uL) - whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( - Result.success( - ChannelLiquidityOptions( - defaultLspBalanceSat = LSP_BALANCE, - minLspBalanceSat = LSP_BALANCE, - maxLspBalanceSat = 1_000_000uL, - maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE, - ) - ) - ) - whenever(blocktankRepo.estimateOrderFee(any(), eq(staleAmount), any())) - .doSuspendableAnswer { staleQuote.await() } - whenever(blocktankRepo.estimateOrderFee(any(), eq(freshAmount), any())) - .thenReturn(Result.success(freshResponse)) - sut.updateLimits() - advanceUntilIdle() - - sut.onReceivingAmountChange(staleAmount.toLong()) - runCurrent() - sut.onReceivingAmountChange(freshAmount.toLong()) - advanceUntilIdle() - - assertEquals(1_000L, sut.spendingUiState.value.feeEstimate) - - staleQuote.complete(Result.success(staleResponse)) - advanceUntilIdle() - - assertEquals(1_000L, sut.spendingUiState.value.feeEstimate) - assertEquals(freshAmount.toLong(), sut.spendingUiState.value.receivingAmount) - } - - @Test - fun `onSpendingAdvancedContinue rejects a receiving capacity the balance cannot fund`() = test { - val clientBalance = 260_000uL - val budget = 265_000uL - val raisedCapacity = LSP_BALANCE * 2u - val affordable = stubFeeResponse(1_000uL) - val unaffordable = stubFeeResponse(6_000uL) - stubSpendableBalances(budget) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(affordable)) - whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(raisedCapacity), any())) - .thenReturn(Result.success(unaffordable)) - sut.updateLimits() - advanceUntilIdle() - sut.onConfirmAmount(clientBalance.toLong()) - advanceUntilIdle() - - sut.transferEffects.test { - sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) - advanceUntilIdle() - - assertIs(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - assertEquals(LSP_BALANCE, sut.spendingUiState.value.lspBalanceSat) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onSpendingAdvancedContinue quotes the capacity when it fits the budget`() = test { - val clientBalance = 260_000uL - val budget = 265_000uL - val response = stubFeeResponse(1_000uL) - stubSpendableBalances(budget) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - sut.updateLimits() - advanceUntilIdle() - sut.onConfirmAmount(clientBalance.toLong()) - advanceUntilIdle() - - sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) - advanceUntilIdle() - - assertTrue(sut.spendingUiState.value.isAdvanced) - assertEquals(clientBalance, sut.spendingUiState.value.clientBalanceSat) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onSpendingAdvancedContinue proceeds when no budget was sized`() = test { - val clientBalance = 260_000uL - val raisedCapacity = LSP_BALANCE * 2u - val unaffordable = stubFeeResponse(6_000uL) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(unaffordable)) - sut.onConfirmAmount(clientBalance.toLong()) - advanceUntilIdle() - assertNull(sut.spendingUiState.value.fundingBudgetSats) - - sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) - advanceUntilIdle() - - assertTrue(sut.spendingUiState.value.isAdvanced) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onSpendingAdvancedContinue stays on the advanced step when the capacity quote fails`() = test { - val clientBalance = 260_000uL - val raisedCapacity = LSP_BALANCE * 2u - val affordable = stubFeeResponse(1_000uL) - stubSpendableBalances(265_000uL) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(affordable)) - sut.updateLimits() - advanceUntilIdle() - sut.onConfirmAmount(clientBalance.toLong()) - advanceUntilIdle() - val defaultQuote = sut.spendingUiState.value - - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())) - .thenReturn(Result.failure(AppError("lsp unreachable"))) - - sut.transferEffects.test { - sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) - advanceUntilIdle() - - assertIs(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - - assertFalse(sut.spendingUiState.value.isAdvanced) - assertEquals(defaultQuote.feeSat, sut.spendingUiState.value.feeSat) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `updateAdvancedTransferValues settles the max on a capacity the balance can fund`() = test { - val quote = previewSpendingState(clientBalanceSat = ADVANCED_CLIENT_BALANCE) - stubSpendableBalances(ADVANCED_BUDGET) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( - Result.success(advancedLiquidityOptions(maxLspBalanceSat = 2_000_000uL)) - ) - stubCapacityPricedFees() - - sut.updateAdvancedTransferValues(quote.clientBalanceSat) - advanceUntilIdle() - - assertEquals(900_000uL, sut.transferValues.value.maxLspBalance) - assertFalse(sut.spendingUiState.value.isLoading) - } - - @Test - fun `updateAdvancedTransferValues leaves an affordable max untouched`() = test { - val quote = previewSpendingState(clientBalanceSat = ADVANCED_CLIENT_BALANCE) - stubSpendableBalances(ADVANCED_BUDGET) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( - Result.success(advancedLiquidityOptions(maxLspBalanceSat = 400_000uL)) - ) - stubCapacityPricedFees() - - sut.updateAdvancedTransferValues(quote.clientBalanceSat) - advanceUntilIdle() - - assertEquals(400_000uL, sut.transferValues.value.maxLspBalance) - } - - @Test - fun `updateAdvancedTransferValues holds the loading state while settling the max`() = test { - val quote = previewSpendingState(clientBalanceSat = ADVANCED_CLIENT_BALANCE) - val pendingQuote = CompletableDeferred>() - stubSpendableBalances(ADVANCED_BUDGET) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( - Result.success(advancedLiquidityOptions(maxLspBalanceSat = 2_000_000uL)) - ) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).doSuspendableAnswer { - pendingQuote.await() - } - - sut.updateAdvancedTransferValues(quote.clientBalanceSat) - advanceUntilIdle() - - assertTrue(sut.spendingUiState.value.isLoading) - - pendingQuote.complete(Result.failure(AppError("no quote"))) - advanceUntilIdle() - - assertFalse(sut.spendingUiState.value.isLoading) - } - - @Test - fun `onConfirmAmount rejects an order the balance can no longer fund`() = test { - val amount = 260_000uL - val response = stubFeeResponse(1_000uL) - stubSpendableBalances(265_000uL) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - sut.updateLimits() - advanceUntilIdle() - // the savings drain after the limits were sized - stubSpendableBalances(100_000uL) - - sut.transferEffects.test { - sut.onConfirmAmount(amount.toLong()) - advanceUntilIdle() - - assertIs(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onSpendingAdvancedContinue rejects a capacity the drained balance can no longer fund`() = test { - val clientBalance = 260_000uL - val raisedCapacity = LSP_BALANCE * 2u - val response = stubFeeResponse(1_000uL) - stubSpendableBalances(265_000uL) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(0uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - sut.updateLimits() - advanceUntilIdle() - sut.onConfirmAmount(clientBalance.toLong()) - advanceUntilIdle() - // the savings drain after the order was placed, before the capacity is raised - stubSpendableBalances(100_000uL) - - sut.transferEffects.test { - sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) - advanceUntilIdle() - - assertIs(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onSpendingAdvancedContinue rejects a capacity the drained device account cannot fund`() = test { - val clientBalance = 100_000uL - val raisedCapacity = LSP_BALANCE * 2u - val response = stubFeeResponse(6_000uL) - stubSpendableBalances(0uL) - blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) - stubHwFundingAccount(balanceSats = ON_CHAIN_BALANCE) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - sut.updateHwLimits(HARDWARE_WALLET_ID) - advanceUntilIdle() - sut.onConfirmAmount(clientBalance.toLong()) - advanceUntilIdle() - // the device account is spent from elsewhere after the limits were sized - stubHwFundingAccount(balanceSats = 50_000uL) - - sut.transferEffects.test { - sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) - advanceUntilIdle() - - assertIs(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onSpendingAdvancedContinue funds a hardware transfer from the device balance`() = test { - val clientBalance = 100_000uL - val raisedCapacity = LSP_BALANCE * 2u - val deviceAffordable = stubFeeResponse(6_000uL) - stubSpendableBalances(0uL) // empty on-chain wallet, as in the hardware e2e - blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) - stubHwFundingAccount(balanceSats = ON_CHAIN_BALANCE) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) - whenever(blocktankRepo.calculateLiquidityOptions(any())) - .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(deviceAffordable)) - sut.updateHwLimits(HARDWARE_WALLET_ID) - advanceUntilIdle() - sut.onConfirmAmount(clientBalance.toLong()) - advanceUntilIdle() - - sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) - advanceUntilIdle() - - assertTrue(sut.spendingUiState.value.isAdvanced) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `prepareSpendingConfirmFunding exposes real mining fee for confirm UI`() = test { - quoteOrder(spendingOrder(feeSat = 98_000uL)) - val selected = listOf(stubUtxo(100_000u)) - stubSpendableBalances(spendable = 100_000u) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(selected)) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - - sut.prepareSpendingConfirmFunding() - advanceUntilIdle() - - val state = sut.spendingUiState.value - assertEquals(true, state.isConfirmFeeReady) - assertEquals(1_000uL, state.miningFeeSats) - assertEquals(false, state.shouldUseSendAll) - assertEquals(100_000uL, state.spendableBalance) - assertEquals(99_000uL, state.confirmLeavingAmountSats) - verify(lightningRepo).calculateTotalFee(eq(98_000uL), eq(WALLET_ADDRESS), any(), anyOrNull(), anyOrNull()) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `prepareSpendingConfirmFunding shows spendable balance as total for send-all`() = test { - quoteOrder(spendingOrder(feeSat = 99_000uL)) - val selected = listOf(stubUtxo(100_000u)) - stubSpendableBalances(spendable = 100_000u) - whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())).thenReturn(Result.success(500uL)) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(selected)) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(500uL)) - - sut.prepareSpendingConfirmFunding() - advanceUntilIdle() - - val state = sut.spendingUiState.value - assertEquals(true, state.isConfirmFeeReady) - assertEquals(500uL, state.miningFeeSats) - assertEquals(true, state.shouldUseSendAll) - assertEquals(100_000uL, state.spendableBalance) - assertEquals(100_000uL, state.confirmLeavingAmountSats) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `repreparing send-all funding keeps the leaving amount until the next plan`() = test { - quoteOrder(spendingOrder(feeSat = 99_000uL)) - val selected = listOf(stubUtxo(100_000u)) - stubSpendableBalances(spendable = 100_000u) - whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())).thenReturn(Result.success(500uL)) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(selected)) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(500uL)) - - sut.prepareSpendingConfirmFunding() - advanceUntilIdle() - assertEquals(100_000uL, sut.spendingUiState.value.confirmLeavingAmountSats) - - whenever(lightningRepo.getBalancesAsync()).doSuspendableAnswer { suspendCancellableCoroutine { } } - sut.prepareSpendingConfirmFunding() - runCurrent() - - val refreshing = sut.spendingUiState.value - assertEquals(false, refreshing.isConfirmFeeReady) - assertEquals(true, refreshing.shouldUseSendAll) - assertEquals(100_000uL, refreshing.confirmLeavingAmountSats) - } - - @Test - fun `onTransferToSpendingConfirm uses send-all when selected inputs would create dust change`() = test { - val order = spendingOrder(feeSat = 99_000uL) - val selected = listOf(stubUtxo(100_000u)) - stubSpendableBalances(spendable = 100_000u) - whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())).thenReturn(Result.success(500uL)) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(selected)) - // totalInput 100000 - feeSat 99000 - normalFee 500 = 500 dust (< Defaults.dustLimit) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(500uL)) - stubSendOnChain() - var fundingPaidEmitted = false - backgroundScope.launch { - sut.transferEffects.collect { effect -> - if (effect is TransferEffect.OnSpendingFundingPaid) { - fundingPaidEmitted = true - } - } - } - - quoteOrder(order) - - prepareConfirm() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - assertEquals(true, sut.spendingUiState.value.isConfirmPaying) - assertTrue(fundingPaidEmitted) - verify(lightningRepo, atLeastOnce()).selectUtxosWithAlgorithm( - targetAmountSats = eq(order.feeSat), - satsPerVByte = any(), - algorithm = eq(CoinSelectionAlgorithm.LARGEST_FIRST), - utxos = anyOrNull(), - ) - verify(lightningRepo).sendOnChain( - address = eq(order.payment?.onchain?.address.orEmpty()), - sats = eq(order.feeSat), - speed = eq(TransactionSpeed.Fast), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = eq(true), - channelId = anyOrNull(), - isMaxAmount = eq(true), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = eq( - OnchainTransferContext( - txTotalSats = 100_000uL, - preTransferOnchainSats = 100_000uL, - originalOrderClientBalanceSats = order.clientBalanceSat, - originalOrderFeeSats = order.feeSat, - ), - ), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - verify(transferRepo).persistAcceptedFunding( - order, - TXID, - OnchainTransferContext( - txTotalSats = 100_000uL, - preTransferOnchainSats = 100_000uL, - originalOrderClientBalanceSats = order.clientBalanceSat, - originalOrderFeeSats = order.feeSat, - ), - ) - verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) - verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) - } - - @Test - fun `onTransferToSpendingConfirm pays no held order when transfers are unavailable`() = test { - val order = spendingOrder(feeSat = 99_000uL) - stubSpendableBalances(spendable = 200_000u) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(listOf(stubUtxo(200_000u)))) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(500uL)) - stubSendOnChain() - val effects = mutableListOf() - backgroundScope.launch { sut.transferEffects.collect { effects += it } } - quoteOrder(order) - prepareConfirm() - whenever(lightningRepo.isTransferBlocked()).thenReturn(true) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - assertTrue(TransferEffect.OnTransferUnavailable in effects) - assertEquals(false, sut.spendingUiState.value.isConfirmPaying) - verify(lightningRepo, never()).sendOnChain( - address = any(), - sats = any(), - speed = any(), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = any(), - channelId = anyOrNull(), - isMaxAmount = any(), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - } - - @Test - fun `onTransferToSpendingConfirm does not drain when normal fee leaves non-dust change`() = test { - val order = spendingOrder(feeSat = 35_341uL) - val selected = listOf(stubUtxo(41_000u)) - stubSpendableBalances(spendable = 41_000u) - whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) - .thenReturn(Result.success(5_659uL)) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(selected)) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(2_830uL)) - stubSendOnChain() - - quoteOrder(order) - - prepareConfirm() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - assertEquals(true, sut.spendingUiState.value.isConfirmPaying) - verify(lightningRepo).sendOnChain( - address = eq(order.payment?.onchain?.address.orEmpty()), - sats = eq(order.feeSat), - speed = eq(TransactionSpeed.Fast), - utxosToSpend = eq(selected), - feeRates = anyOrNull(), - isTransfer = eq(true), - channelId = anyOrNull(), - isMaxAmount = eq(false), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = eq( - OnchainTransferContext( - txTotalSats = 38_171uL, - preTransferOnchainSats = 41_000uL, - originalOrderClientBalanceSats = order.clientBalanceSat, - originalOrderFeeSats = order.feeSat, - ), - ), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - verify(lightningRepo, never()).sendOnChain( - address = any(), - sats = any(), - speed = any(), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = any(), - channelId = anyOrNull(), - isMaxAmount = eq(true), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) - } - - @Test - fun `onTransferToSpendingConfirm surfaces error when fixed send fails without draining`() = test { - val order = spendingOrder(feeSat = 98_000uL) - val selected = listOf(stubUtxo(100_000u)) - stubSpendableBalances(spendable = 100_000u) - whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(selected)) - // 100000 - 98000 - 1000 = 1000, above dust → fixed send; drain would still cover order. - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever( - lightningRepo.sendOnChain( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ), - ).thenReturn(Result.failure(AppError("Coin selection failed"))) - - quoteOrder(order) - - prepareConfirm() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - assertEquals(false, sut.spendingUiState.value.isConfirmPaying) - verify(lightningRepo, times(1)).sendOnChain( - address = eq(order.payment?.onchain?.address.orEmpty()), - sats = eq(order.feeSat), - speed = eq(TransactionSpeed.Fast), - utxosToSpend = eq(selected), - feeRates = anyOrNull(), - isTransfer = eq(true), - channelId = anyOrNull(), - isMaxAmount = eq(false), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - verify(lightningRepo, never()).sendOnChain( - address = any(), - sats = any(), - speed = any(), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = any(), - channelId = anyOrNull(), - isMaxAmount = eq(true), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - verify(cacheStore, never()).addPaidOrder(any(), any()) - } - - @Test - fun `accepted confirmation rejects changed original client balance`() = test { - assertChangedFundingTermsRejected("balance") - } - - @Test - fun `accepted confirmation rejects changed original service fee`() = test { - assertChangedFundingTermsRejected("fee") - } - - @Test - fun `accepted confirmation rejects changed original funding address`() = test { - assertChangedFundingTermsRejected("address") - } - - private suspend fun TestScope.assertChangedFundingTermsRejected(changedTerm: String) { - val originalOrder = spendingOrder(feeSat = 98_000uL) - val original = OnchainTransferContext( - 99_000uL, - 125_000uL, - originalOrderClientBalanceSats = originalOrder.clientBalanceSat, - originalOrderFeeSats = originalOrder.feeSat, - ) - val attempt = acceptedFundingAttempt(originalOrder, original).let { - if (changedTerm == "address") it.copy(address = "different-original-address") else it - } - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - val changed = when (changedTerm) { - "balance" -> originalOrder.copy(clientBalanceSat = originalOrder.clientBalanceSat + 1uL) - "fee" -> originalOrder.copy(feeSat = originalOrder.feeSat + 1uL) - else -> originalOrder - } - quoteOrder(changed) - prepareConfirm() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - verify(transferRepo, never()).persistAcceptedFunding(any(), any(), anyOrNull()) - verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) - verifySendOnChain(sats = changed.feeSat, count = 0) - assertFalse(sut.spendingUiState.value.isConfirmPaying) - } - - @Test - fun `accepted transfer resumes after transfer storage failure without another send`() = test { - val order = spendingOrder(feeSat = 98_000uL) - val original = OnchainTransferContext( - 99_000uL, - 125_000uL, - originalOrderClientBalanceSats = order.clientBalanceSat, - originalOrderFeeSats = order.feeSat, - ) - val attempt = acceptedFundingAttempt(order, original) - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - whenever(transferRepo.persistAcceptedFunding(order, TXID, original)) - .thenReturn(Result.failure(AppError("transfer storage unavailable")), Result.success(Unit)) - val effects = mutableListOf() - backgroundScope.launch { sut.transferEffects.collect { effects.add(it) } } - quoteOrder(order) - prepareConfirm() - effects.clear() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) - assertEquals(order, sut.spendingUiState.value.order) - assertFalse(sut.spendingUiState.value.isConfirmPaying) - assertTrue(effects.isEmpty()) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - verify(lightningRepo).completeAcceptedTransferFollowup(order.id, TXID) - verifySendOnChain(sats = order.feeSat, count = 0) - verify(transferRepo, times(2)).persistAcceptedFunding(order, TXID, original) - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - assertEquals(listOf(TransferEffect.OnSpendingFundingPaid), effects) - } - - @Test - fun `durably paid accepted funding survives local followup failure without a second order`() = test { - assertPaidFundingSurvivesLocalFailure(resumedEvidence = null) - } - - @Test - fun `durably paid resumed accepted funding survives local followup failure without a second order`() = test { - assertPaidFundingSurvivesLocalFailure(resumedEvidence = OnchainSendEvidence.Accepted) - } - - @Test - fun `durably paid observed funding survives local followup failure without a second order`() = test { - assertPaidFundingSurvivesLocalFailure(resumedEvidence = OnchainSendEvidence.Observed) - } - - private suspend fun TestScope.assertPaidFundingSurvivesLocalFailure(resumedEvidence: OnchainSendEvidence?) { - val order = spendingOrder(feeSat = 98_000uL) - val original = OnchainTransferContext( - 99_000uL, - 110_000uL, - originalOrderClientBalanceSats = order.clientBalanceSat, - originalOrderFeeSats = order.feeSat, - ) - val attempt = acceptedFundingAttempt(order, original).copy( - evidence = resumedEvidence ?: OnchainSendEvidence.Accepted, - ) - var retainedAttempt = if (resumedEvidence == null) null else attempt - whenever(lightningRepo.currentOnchainSendAttempt()).doSuspendableAnswer { retainedAttempt } - whenever(lightningRepo.completeAcceptedTransferFollowup(order.id, TXID)).doSuspendableAnswer { - throw AppError("accepted activity readback unavailable") - } - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - val toasts = collectToasts() - val effects = mutableListOf() - backgroundScope.launch { sut.transferEffects.collect { effects.add(it) } } - quoteOrder(order) - whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn( - Result.success(order), - Result.success(order.copy(id = "second-order")), - ) - prepareConfirm() - effects.clear() - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - // Simulate the background resumer repairing the original activity and acknowledging the guard. - // A second swipe on the still-mounted confirmation must not create or fund another order. - retainedAttempt = attempt.copy(localFollowupComplete = true) - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verifySendOnChain(sats = order.feeSat, count = if (resumedEvidence == null) 1 else 0) - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - verify(transferRepo).persistAcceptedFunding(order, TXID, original) - verify(cacheStore).addPaidOrder(order.id, TXID) - verify(lightningRepo).completeAcceptedTransferFollowup(order.id, TXID) - assertEquals(listOf(TransferEffect.OnSpendingFundingPaid), effects) - assertTrue(sut.spendingUiState.value.isConfirmPaying) - assertTrue(toasts.isEmpty()) - } - - @Test - fun `accepted transfer recovery preserves original balance totals`() = test { - val order = spendingOrder(feeSat = 98_000uL) - val original = OnchainTransferContext( - 99_000uL, - 125_000uL, - originalOrderClientBalanceSats = order.clientBalanceSat, - originalOrderFeeSats = order.feeSat, - ) - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedFundingAttempt(order, original)) - quoteOrder(order) - prepareConfirm() - stubSpendableBalances(7_000uL) - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verify(transferRepo).persistAcceptedFunding(order, TXID, original) - verifySendOnChain(sats = order.feeSat, count = 0) - } - - @Test - fun `accepted transfer without original context stays blocked instead of inventing balance totals`() = test { - val order = spendingOrder(feeSat = 98_000uL) - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedFundingAttempt(order, null)) - whenever(transferRepo.persistAcceptedFunding(order, TXID, null)) - .thenReturn(Result.failure(AppError("missing original context"))) - quoteOrder(order) - prepareConfirm() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) - verifySendOnChain(sats = order.feeSat, count = 0) - assertFalse(sut.spendingUiState.value.isConfirmPaying) - } - - private fun acceptedFundingAttempt(order: IBtOrder, original: OnchainTransferContext?) = OnchainSendAttempt( - walletId = WalletScope.default, attemptId = "attempt", requestId = null, orderId = order.id, - address = order.payment?.onchain?.address.orEmpty(), amountSats = order.feeSat, - isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, - channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Accepted, txid = TXID, - transferContext = original?.copy( - originalOrderClientBalanceSats = original.originalOrderClientBalanceSats ?: order.clientBalanceSat, - originalOrderFeeSats = original.originalOrderFeeSats ?: order.feeSat, - ), - ) - - @Test - fun `unknown transfer funding does not mark order paid`() = test { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 100_000u) - whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever { lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) } - .thenReturn(Result.success(listOf(stubUtxo(100_000u)))) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever( - lightningRepo.sendOnChain( - address = any(), - sats = any(), - speed = any(), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = any(), - channelId = anyOrNull(), - isMaxAmount = any(), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ), - ).thenReturn(Result.success(OnchainSendOutcome.Unknown(TXID))) - quoteOrder(order) - - prepareConfirm() - val retained = acceptedFundingAttempt(order, null).copy(evidence = OnchainSendEvidence.Unknown) - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(null, retained) - val effects = mutableListOf() - val collector = launch { sut.transferEffects.collect { effects += it } } - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - assertEquals(listOf(TransferEffect.OnFundingPending(retained)), effects) - collector.cancel() - - verify(cacheStore, never()).addPaidOrder(any(), any()) - verify(transferRepo, never()).createTransfer( - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - ) - } - - @Test - fun `blocked transfer opens the retained previous order`() = test { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 100_000u) - whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever { lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) } - .thenReturn(Result.success(listOf(stubUtxo(100_000u)))) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever( - lightningRepo.sendOnChain( - address = any(), - sats = any(), - speed = any(), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = any(), - channelId = anyOrNull(), - isMaxAmount = any(), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ), - ).thenReturn(Result.success(OnchainSendOutcome.Unknown(TXID))) - quoteOrder(order) - - prepareConfirm() - val retained = acceptedFundingAttempt( - order, - null - ).copy(orderId = "previous-order", evidence = OnchainSendEvidence.Unknown) - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(null, retained) - val effects = mutableListOf() - val collector = launch { sut.transferEffects.collect { effects += it } } - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - assertEquals(listOf(TransferEffect.OnFundingPending(retained)), effects) - collector.cancel() - - verify(cacheStore, never()).addPaidOrder(any(), any()) - verify(transferRepo, never()).createTransfer( - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - ) - } - - @Test - fun `confirmation blocks replacing or clearing the transfer while creating its order`() = test { - val order = spendingOrder(feeSat = 98_000uL) - val creation = CompletableDeferred>() - quoteOrder(order) - whenever(blocktankRepo.createOrder(any(), any(), any())).doSuspendableAnswer { creation.await() } - stubSpendableBalances(110_000uL) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - stubSendOnChain() - prepareConfirm() - - sut.onTransferToSpendingConfirm() - runCurrent() - sut.onConfirmAmount(50_000) - sut.onSpendingAdvancedContinue(60_000) - sut.onUseDefaultLspBalanceClick() - sut.resetSpendingState() - runCurrent() - - assertEquals(order.clientBalanceSat, sut.spendingUiState.value.clientBalanceSat) - assertEquals(order.lspBalanceSat, sut.spendingUiState.value.lspBalanceSat) - assertTrue(sut.spendingUiState.value.isBusy) - - creation.complete(Result.success(order)) - advanceUntilIdle() - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - verify(cacheStore).addPaidOrder(order.id, TXID) - } - - @Test - fun `onTransferToSpendingConfirm pays the order it already created when swiped again`() = test { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 110_000u) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(listOf(stubUtxo(110_000u)))) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever( - lightningRepo.sendOnChain( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ), - ).thenReturn( - Result.failure(AppError("Coin selection failed")), - Result.success(OnchainSendOutcome.Accepted(TXID)), - ) - quoteOrder(order) - - prepareConfirm() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) - } - - @Test - fun `onTransferToSpendingConfirm requires another swipe when the created order costs more`() = test { - val estimate = spendingOrder(feeSat = 98_000uL) - val createdOrder = estimate.copy( - feeSat = 99_000uL, - serviceFeeSat = estimate.serviceFeeSat + 1_000uL, - ) - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - stubFeesChangedStrings() - val toasts = collectToasts() - quoteOrder(estimate) - whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(createdOrder)) - prepareConfirm() - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - val shown = sut.spendingUiState.value - assertEquals(createdOrder, shown.order) - assertEquals(createdOrder.feeSat, shown.feeSat) - assertFalse(shown.isConfirmPaying) - assertEquals(100_000uL, shown.confirmLeavingAmountSats) - verifySendOnChain(sats = createdOrder.feeSat, count = 0) - assertFeesChangedToast(toasts, FEES_CHANGED_SERVICE, delta = 1_000uL) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verifySendOnChain(sats = createdOrder.feeSat, count = 1) - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - verify(cacheStore).addPaidOrder(createdOrder.id, TXID) - assertEquals(1, toasts.size) - } - - @Test - fun `onTransferToSpendingConfirm pays on the first swipe when the plan matches the screen`() = test { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - val toasts = collectToasts() - quoteOrder(order) - prepareConfirm() - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verifySendOnChain(sats = order.feeSat, count = 1) - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - verify(cacheStore).addPaidOrder(order.id, TXID) - assertTrue(toasts.isEmpty()) - } - - @Test - fun `onTransferToSpendingConfirm pays the rebuilt plan on the first swipe when the mining fee drops`() = test { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 2_000uL) - stubSendOnChain() - val toasts = collectToasts() - quoteOrder(order) - prepareConfirm() - assertEquals(100_000uL, sut.spendingUiState.value.confirmLeavingAmountSats) - stubSingleUtxoFunding(miningFee = 1_000uL) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verifySendOnChain(sats = order.feeSat, count = 1) - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - verify(cacheStore).addPaidOrder(order.id, TXID) - assertTrue(toasts.isEmpty()) - } - - @Test - fun `onTransferToSpendingConfirm explains a higher mining fee and pays the kept plan on the next swipe`() = test { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - stubFeesChangedStrings() - val toasts = collectToasts() - quoteOrder(order) - prepareConfirm() - assertEquals(99_000uL, sut.spendingUiState.value.confirmLeavingAmountSats) - stubSingleUtxoFunding(miningFee = 2_500uL) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - val shown = sut.spendingUiState.value - assertFalse(shown.isConfirmPaying) - assertTrue(shown.isConfirmFeeReady) - assertEquals(2_500uL, shown.miningFeeSats) - assertEquals(100_500uL, shown.confirmLeavingAmountSats) - verifySendOnChain(sats = order.feeSat, count = 0) - verify(cacheStore, never()).addPaidOrder(any(), any()) - assertFeesChangedToast(toasts, FEES_CHANGED_NETWORK, delta = 1_500uL) - - stubSingleUtxoFunding(miningFee = 1_000uL) - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verifySendOnChain(sats = order.feeSat, count = 1) - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - verify(cacheStore).addPaidOrder(order.id, TXID) - assertEquals(1, toasts.size) - } - - @Test - fun `onTransferToSpendingConfirm compares with the amounts shown when the user swiped`() = test { - val order = spendingOrder(feeSat = 98_000uL) - val creation = CompletableDeferred>() - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - stubFeesChangedStrings() - val toasts = collectToasts() - quoteOrder(order) - prepareConfirm() - whenever(blocktankRepo.createOrder(any(), any(), any())).doSuspendableAnswer { creation.await() } - - sut.onTransferToSpendingConfirm() - runCurrent() - stubSingleUtxoFunding(miningFee = 2_500uL) - prepareConfirm() - assertEquals(2_500uL, sut.spendingUiState.value.miningFeeSats) - creation.complete(Result.success(order)) - advanceUntilIdle() - - verifySendOnChain(sats = order.feeSat, count = 0) - assertFeesChangedToast(toasts, FEES_CHANGED_NETWORK, delta = 1_500uL) - } - - @Test - fun `onTransferToSpendingConfirm shows the fee increase in fiat when fiat is the primary display`() = test { - val order = spendingOrder(feeSat = 98_000uL) - val increase = ConvertedAmount( - value = BigDecimal("0.95"), - formatted = "0.95", - symbol = "$", - currency = "USD", - flag = "", - sats = 1_500, - ) - currencyState.value = CurrencyState(primaryDisplay = PrimaryDisplay.FIAT) - whenever(currencyRepo.convertSatsToFiat(eq(1_500L), anyOrNull())).thenReturn(Result.success(increase)) - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - stubFeesChangedStrings() - val toasts = collectToasts() - quoteOrder(order) - prepareConfirm() - stubSingleUtxoFunding(miningFee = 2_500uL) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verifySendOnChain(sats = order.feeSat, count = 0) - assertEquals( - FEES_CHANGED_NETWORK.replace("{amount}", increase.formattedWithSymbol()), - toasts.single().description, - ) - } - - @Test - fun `onTransferToSpendingConfirm shows the fee increase in the classic bitcoin unit`() = test { - currencyState.value = CurrencyState( - primaryDisplay = PrimaryDisplay.BITCOIN, - displayUnit = BitcoinDisplayUnit.CLASSIC, - ) - - val toasts = triggerNetworkFeesChanged() - - assertEquals( - FEES_CHANGED_NETWORK.replace("{amount}", "$BITCOIN_SYMBOL 0.00001500"), - toasts.single().description, - ) - } - - @Test - fun `onTransferToSpendingConfirm shows the fee increase in bitcoin when fiat has no rate`() = test { - currencyState.value = CurrencyState(primaryDisplay = PrimaryDisplay.FIAT) - - val toasts = triggerNetworkFeesChanged() - - assertFeesChangedToast(toasts, FEES_CHANGED_NETWORK, delta = 1_500uL) - } - - @Test - fun `onTransferToSpendingConfirm shows the fee increase in classic bitcoin when fiat has no rate`() = test { - currencyState.value = CurrencyState( - primaryDisplay = PrimaryDisplay.FIAT, - displayUnit = BitcoinDisplayUnit.CLASSIC, - ) - - val toasts = triggerNetworkFeesChanged() - - assertEquals( - FEES_CHANGED_NETWORK.replace("{amount}", "$BITCOIN_SYMBOL 0.00001500"), - toasts.single().description, - ) - } - - @Test - fun `onTransferToSpendingConfirm pays with the fee rates of the plan it held`() = test { - val order = spendingOrder(feeSat = 98_000uL) - val heldRates = FeeRates(fast = 5u, mid = 3u, slow = 1u) - val laterRates = FeeRates(fast = 50u, mid = 30u, slow = 10u) - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - stubFeesChangedStrings() - whenever { lightningRepo.getFeeRates() }.thenReturn(Result.success(heldRates)) - val toasts = collectToasts() - quoteOrder(order) - prepareConfirm() - stubSingleUtxoFunding(miningFee = 2_500uL) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - assertFeesChangedToast(toasts, FEES_CHANGED_NETWORK, delta = 1_500uL) - whenever { lightningRepo.getFeeRates() }.thenReturn(Result.success(laterRates)) - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verify(lightningRepo, atLeastOnce()).calculateTotalFee(any(), any(), any(), anyOrNull(), eq(heldRates)) - verify(lightningRepo, never()).calculateTotalFee(any(), any(), any(), anyOrNull(), eq(laterRates)) - verify(lightningRepo).sendOnChain( - address = any(), - sats = eq(order.feeSat), - speed = any(), - utxosToSpend = anyOrNull(), - feeRates = eq(heldRates), - isTransfer = any(), - channelId = anyOrNull(), - isMaxAmount = any(), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - } - - @Test - fun `onTransferToSpendingConfirm shows the order fee when the wallet cannot fund a pricier order`() = test { - val estimate = spendingOrder(feeSat = 98_000uL) - val createdOrder = estimate.copy( - feeSat = 99_000uL, - serviceFeeSat = estimate.serviceFeeSat + 1_000uL, - ) - stubSpendableBalances(spendable = 98_500uL) - stubSingleUtxoFunding(miningFee = 500uL) - stubSendOnChain() - stubFeesChangedStrings() - val toasts = collectToasts() - quoteOrder(estimate) - whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(createdOrder)) - prepareConfirm() - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.failure(AppError("no funding utxos"))) - whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())).thenReturn(Result.success(500uL)) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - val shown = sut.spendingUiState.value - assertEquals(createdOrder.feeSat, shown.feeSat) - assertFalse(shown.isConfirmPaying) - verifySendOnChain(sats = createdOrder.feeSat, count = 0) - assertFeesChangedToast(toasts, FEES_CHANGED_SERVICE, delta = 1_000uL) - } - - @Test - fun `onTransferToSpendingConfirm keeps the shown plan when the rebuilt fee estimate fails`() = test { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - val toasts = collectToasts() - quoteOrder(order) - prepareConfirm() - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.failure(AppError("fee estimate unavailable"))) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - val shown = sut.spendingUiState.value - assertFalse(shown.isConfirmPaying) - assertTrue(shown.isConfirmFeeReady) - assertEquals(1_000uL, shown.miningFeeSats) - assertEquals(99_000uL, shown.confirmLeavingAmountSats) - verifySendOnChain(sats = order.feeSat, count = 0) - assertEquals(Toast.ToastType.ERROR, toasts.single().type) - - stubSingleUtxoFunding(miningFee = 1_000uL) - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verifySendOnChain(sats = order.feeSat, count = 1) - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - verify(cacheStore).addPaidOrder(order.id, TXID) - } - - @Test - fun `onTransferToSpendingConfirm pays on the first swipe when the created order costs less`() = test { - val estimate = spendingOrder(feeSat = 98_000uL) - val createdOrder = estimate.copy( - feeSat = 97_000uL, - serviceFeeSat = estimate.serviceFeeSat - 1_000uL, - ) - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - val toasts = collectToasts() - quoteOrder(estimate) - whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(createdOrder)) - prepareConfirm() - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verifySendOnChain(sats = createdOrder.feeSat, count = 1) - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - verify(cacheStore).addPaidOrder(createdOrder.id, TXID) - assertTrue(toasts.isEmpty()) - } - - @Test - fun `a new quote does not reuse an unpaid order from the previous confirmation`() = test { - val estimate = spendingOrder(feeSat = 98_000uL) - val firstOrder = estimate.copy( - id = "order_first", - feeSat = 99_000uL, - serviceFeeSat = estimate.serviceFeeSat + 1_000uL, - ) - val nextOrder = estimate.copy(id = "order_next") - stubSpendableBalances(spendable = 110_000uL) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(listOf(stubUtxo(110_000uL)))) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - stubSendOnChain() - quoteOrder(estimate) - whenever(blocktankRepo.createOrder(any(), any(), any())) - .thenReturn(Result.success(firstOrder), Result.success(nextOrder)) - - prepareConfirm() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - assertEquals(firstOrder, sut.spendingUiState.value.order) - - sut.onConfirmAmount(estimate.clientBalanceSat.toLong()) - advanceUntilIdle() - assertNull(sut.spendingUiState.value.order) - prepareConfirm() - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) - verify(cacheStore).addPaidOrder(nextOrder.id, TXID) - } - - @Test - fun `a paid savings transfer does not block quoting the next transfer`() = test { - val paidOrder = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 110_000uL) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - stubSendOnChain() - quoteOrder(paidOrder) - - prepareConfirm() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - verify(cacheStore).addPaidOrder(eq(paidOrder.id), eq(TXID)) - - val nextAmount = paidOrder.clientBalanceSat.toLong() - 1_000 - sut.onConfirmAmount(nextAmount) - advanceUntilIdle() - - assertEquals(nextAmount.toULong(), sut.spendingUiState.value.clientBalanceSat) - assertFalse(sut.spendingUiState.value.isConfirmPaying) - } - - @Test - fun `onTransferToSpendingConfirm stays on the confirm step when the order cannot be created`() = test { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 110_000u) - stubSendOnChain() - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - quoteOrder(order) - val quote = sut.spendingUiState.value - whenever(blocktankRepo.createOrder(any(), any(), any())) - .thenReturn(Result.failure(AppError("lsp unreachable"))) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - toastJob.cancel() - - val state = sut.spendingUiState.value - assertFalse(state.isConfirmPaying) - assertNull(state.order) - assertEquals(quote, state) - assertEquals(1, toasts.size) - verify(lightningRepo, never()).sendOnChain( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - verify(cacheStore, never()).addPaidOrder(any(), any()) - } - - @Test - fun `onConfirmAmount drops the created order so the next confirm creates a fresh one`() = test { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 110_000u) - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(listOf(stubUtxo(110_000u)))) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever( - lightningRepo.sendOnChain( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ), - ).thenReturn(Result.failure(AppError("Coin selection failed"))) - quoteOrder(order) - prepareConfirm() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - assertEquals(order, sut.spendingUiState.value.order) - - sut.onConfirmAmount(order.clientBalanceSat.toLong() + 1) - advanceUntilIdle() - - assertNull(sut.spendingUiState.value.order) - sut.spendingUiState.value - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) - } - - @Test - fun `onUseDefaultLspBalanceClick restores the default quote without an order`() = test { - val order = spendingOrder(feeSat = 98_000uL) - val raisedCapacity = order.lspBalanceSat * 2u - quoteOrder(order) - val defaultQuote = sut.spendingUiState.value - - sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) - advanceUntilIdle() - - val advanced = sut.spendingUiState.value - assertTrue(advanced.isAdvanced) - assertEquals(raisedCapacity, advanced.lspBalanceSat) - - sut.onUseDefaultLspBalanceClick() - advanceUntilIdle() - - val restored = sut.spendingUiState.value - assertFalse(restored.isAdvanced) - assertEquals(defaultQuote.lspBalanceSat, restored.lspBalanceSat) - assertNull(restored.order) - verify(blocktankRepo, never()).createOrder(any(), any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val broadcast = HwFundingBroadcastResult( - txId = TXID, - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE, - totalSpent = order.feeSat + MINING_FEE, - ) - val signed = signedFunding(funding) - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertEquals(MINING_FEE, sut.spendingUiState.value.hwMiningFeeSats) - verify(hwWalletRepo).composeFundingTransaction( - eq(HARDWARE_WALLET_ID), - eq(order.payment?.onchain?.address.orEmpty()), - eq(order.feeSat), - eq(FEE_RATE), - ) - verify(hwWalletRepo).signFunding(eq(HARDWARE_WALLET_ID), eq(funding)) - verify(hwWalletRepo).broadcastFunding(signed) - verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) - verify(transferRepo).createTransfer( - eq(TransferType.TO_SPENDING), - eq(order.clientBalanceSat.toLong()), - isNull(), - eq(TXID), - eq(order.id), - isNull(), - isNull(), - isNull(), - ) - verify(transferRepo).createPendingToSpendingActivity( - eq(order), - eq(TXID), - eq(MINING_FEE), - eq(FEE_RATE), - eq(HARDWARE_WALLET_ID), - ) - verify(hwWalletRepo).ensureConnected(HARDWARE_WALLET_ID) - verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) - } - - @Test - fun `a paid hardware order is not paid again by the next transfer of the same amount`() = test { - val paidOrder = previewBtOrder() - val nextOrder = paidOrder.copy( - id = "order_next", - payment = paidOrder.payment?.copy( - onchain = paidOrder.payment?.onchain?.copy(address = "bcrt1qnextorderaddress"), - ), - ) - stubHwFundingSuccess(paidOrder) - stubSpendableBalances(spendable = ON_CHAIN_BALANCE) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - stubSendOnChain() - quoteOrder(paidOrder) - whenever(blocktankRepo.createOrder(any(), any(), any())) - .thenReturn(Result.success(paidOrder), Result.success(nextOrder)) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - verify(cacheStore).addPaidOrder(eq(paidOrder.id), eq(TXID)) - - sut.onConfirmAmount(paidOrder.clientBalanceSat.toLong()) - advanceUntilIdle() - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verify(lightningRepo, never()).sendOnChain( - eq(paidOrder.payment?.onchain?.address.orEmpty()), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm reconnects and retries after THP channel failure`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signed = signedFunding(funding) - val broadcast = HwFundingBroadcastResult( - txId = TXID, - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE, - totalSpent = funding.totalSpent, - ) - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn( - Result.failure(AppError(TrezorException.ProtocolException("THP decryption error: aead::Error"))), - Result.success(signed), - ) - whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - verify(hwWalletRepo, times(2)).ensureConnected(HARDWARE_WALLET_ID) - verify(hwWalletRepo, times(2)).signFunding(HARDWARE_WALLET_ID, funding) - verify(hwWalletRepo).broadcastFunding(signed) - verify(cacheStore).addPaidOrder(order.id, TXID) - } - - @Test - fun `onTransferToSpendingHwConfirm asks for the passphrase when the hidden wallet session is gone`() = test { - val order = previewBtOrder() - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) - whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertTrue(sut.spendingUiState.value.isHwPassphraseRequired) - assertFalse(sut.spendingUiState.value.isSigning) - verify(hwWalletRepo, never()).ensureConnected(any()) - verify(hwWalletRepo, never()).signFunding(any(), any()) - } - - @Test - fun `asks for the passphrase when the device session belongs to another identity`() = test { - val order = previewBtOrder() - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.failure(HwPassphraseRequiredError())) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertTrue(sut.spendingUiState.value.isHwPassphraseRequired) - verify(hwWalletRepo, never()).signFunding(any(), any()) - } - - @Test - fun `retries a pending broadcast without asking for the passphrase`() = test { - // Rebroadcasting an already signed transaction is electrum-only and never reaches the - // device, so holding it behind a passphrase prompt strands a signed transfer. - val order = previewBtOrder() - val address = order.payment?.onchain?.address.orEmpty() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signed = signedFunding(funding) - val broadcast = HwFundingBroadcastResult( - txId = TXID, - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE, - totalSpent = order.feeSat + MINING_FEE, - ) - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)) - .thenReturn(Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed")))) - .thenReturn(Result.success(broadcast)) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - assertTrue(sut.spendingUiState.value.hasPendingHwBroadcast) - whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertFalse(sut.spendingUiState.value.isHwPassphraseRequired) - verify(hwWalletRepo, times(2)).broadcastFunding(signed) - verify(hwWalletRepo, times(1)).signFunding(any(), any()) - } - - @Test - fun `cancelHardwareTransfer clears the passphrase prompt`() = test { - val order = previewBtOrder() - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) - whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - assertTrue(sut.spendingUiState.value.isHwPassphraseRequired) - - sut.cancelHardwareTransfer() - advanceUntilIdle() - - assertFalse(sut.spendingUiState.value.isHwPassphraseRequired) - assertFalse(sut.spendingUiState.value.isVerifyingHwPassphrase) - } - - @Test - fun `onHwPassphraseSubmit signs once the reopened wallet matches`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signed = signedFunding(funding) - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true, false) - whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") } - .thenReturn(Result.success(Unit)) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn( - Result.success( - HwFundingBroadcastResult( - txId = TXID, - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE, - totalSpent = order.feeSat + MINING_FEE, - ) - ) - ) - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - sut.onHwPassphraseSubmit(HARDWARE_WALLET_ID, "secret") - advanceUntilIdle() - - assertFalse(sut.spendingUiState.value.isHwPassphraseRequired) - verify(hwWalletRepo).reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") - verify(hwWalletRepo).signFunding(eq(HARDWARE_WALLET_ID), eq(funding)) - } - - @Test - fun `dismissing the passphrase prompt stops the reopen from starting a signature`() = test { - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) - whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") } - .thenReturn(Result.success(Unit)) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - - sut.onHwPassphraseSubmit(HARDWARE_WALLET_ID, "secret") - sut.onHwPassphraseDismiss() - advanceUntilIdle() - - assertFalse(sut.spendingUiState.value.isHwPassphraseRequired) - assertFalse(sut.spendingUiState.value.isVerifyingHwPassphrase) - verify(hwWalletRepo, never()).ensureConnected(any()) - verify(hwWalletRepo, never()).signFunding(any(), any()) - } - - @Test - fun `onHwPassphraseSubmit does not sign when the passphrase opens another wallet`() = test { - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) - whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) - whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "wrong") } - .thenReturn(Result.failure(HwPassphraseMismatchError())) - whenever(context.getString(R.string.hardware__passphrase_mismatch)).thenReturn(PASSPHRASE_MISMATCH) - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - - sut.onHwPassphraseSubmit(HARDWARE_WALLET_ID, "wrong") - advanceUntilIdle() - toastJob.cancel() - - assertEquals(PASSPHRASE_MISMATCH, toasts.single().description) - verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - } - - @Test - fun `onTransferToSpendingHwConfirm composes with fallback fee rate when fee lookup fails`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FALLBACK_FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FALLBACK_FEE_RATE, - ) - val broadcast = HwFundingBroadcastResult( - txId = TXID, - miningFeeSats = MINING_FEE, - feeRate = FALLBACK_FEE_RATE, - totalSpent = order.feeSat + MINING_FEE, - ) - val signed = signedFunding(funding, feeRate = FALLBACK_FEE_RATE) - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) - .thenReturn(Result.failure(AppError("fee unavailable"))) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - verify(lightningRepo).getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull()) - verify(hwWalletRepo).composeFundingTransaction( - eq(HARDWARE_WALLET_ID), - eq(order.payment?.onchain?.address.orEmpty()), - eq(order.feeSat), - eq(FALLBACK_FEE_RATE), - ) - } - - @Test - fun `onTransferToSpendingHwConfirm aborts when hardware reconnect fails`() = test { - val order = previewBtOrder() - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.failure(AppError("no device"))) - whenever(hwWalletRepo.isKnownBluetoothDevice(HARDWARE_WALLET_ID)).thenReturn(false) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - verify(hwWalletRepo).ensureConnected(HARDWARE_WALLET_ID) - verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) - verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - } - - @Test - fun `cancelHardwareTransfer stops an in-flight hardware transfer`() = test { - val order = previewBtOrder() - val connectResult = CompletableDeferred>() - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)).doSuspendableAnswer { connectResult.await() } - whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit)) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - runCurrent() - assertEquals(true, sut.spendingUiState.value.isSigning) - - sut.cancelHardwareTransfer() - advanceUntilIdle() - - assertEquals(false, sut.spendingUiState.value.isSigning) - assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) - verify(hwWalletRepo).disconnectStaleSession(HARDWARE_WALLET_ID) - verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) - verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - } - - @Test - fun `hardware sign screen can be left while the device connects`() = test { - val order = previewBtOrder() - val connectResult = CompletableDeferred>() - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)).doSuspendableAnswer { connectResult.await() } - whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit)) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - runCurrent() - - assertTrue(sut.spendingUiState.value.isBusy) - assertTrue(sut.spendingUiState.value.isConnectingDevice) - assertTrue(sut.spendingUiState.value.canLeave) - - sut.cancelHardwareTransfer() - advanceUntilIdle() - - assertFalse(sut.spendingUiState.value.isConnectingDevice) - verify(hwWalletRepo, never()).signFunding(any(), any()) - } - - @Test - fun `hardware sign screen cannot be left while the device signs`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signResult = CompletableDeferred>() - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())).doSuspendableAnswer { signResult.await() } - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - runCurrent() - - assertTrue(sut.spendingUiState.value.isBusy) - assertFalse(sut.spendingUiState.value.isConnectingDevice) - assertFalse(sut.spendingUiState.value.canLeave) - - signResult.complete(Result.failure(AppError("cancelled"))) - advanceUntilIdle() - } - - @Test - fun `onTransferToSpendingHwConfirm shows connection guidance for bluetooth reconnect failure`() = test { - val order = previewBtOrder() - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.failure(AppError("no device"))) - whenever(hwWalletRepo.isKnownBluetoothDevice(HARDWARE_WALLET_ID)).thenReturn(true) - whenever(context.getString(R.string.hardware__connect_title)).thenReturn(CONNECT_TITLE) - whenever(context.getString(R.string.hardware__connect_error)).thenReturn(CONNECT_DESCRIPTION) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - toastJob.cancel() - - assertEquals(Toast.ToastType.INFO, toasts.single().type) - assertEquals(CONNECT_TITLE, toasts.single().title) - assertEquals(CONNECT_DESCRIPTION, toasts.single().description) - verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm shows wallet mismatch when the device holds another wallet`() = test { - val order = previewBtOrder() - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.failure(HwWalletMismatchError())) - whenever(context.getString(R.string.common__error)).thenReturn("Error") - whenever(context.getString(R.string.hardware__wallet_mismatch)).thenReturn(WALLET_MISMATCH) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - toastJob.cancel() - - assertEquals(Toast.ToastType.ERROR, toasts.single().type) - assertEquals(WALLET_MISMATCH, toasts.single().description) - verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm disconnects stale session when signing fails with timeout`() = test { - val order = previewBtOrder() - val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.failure(timeout)) - whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit)) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - verify(hwWalletRepo).disconnectStaleSession(HARDWARE_WALLET_ID) - verify(cacheStore, never()).addPaidOrder(any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm disconnects when HW_SIGN_TIMEOUT elapses`() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - Dispatchers.setMain(dispatcher) - try { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())) - .thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())).doSuspendableAnswer { - delay(Long.MAX_VALUE) - Result.success(signedFunding(funding)) - } - whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit)) - - val viewModel = TransferViewModel( - context = context, - lightningRepo = lightningRepo, - blocktankRepo = blocktankRepo, - hwWalletRepo = hwWalletRepo, - walletRepo = walletRepo, - settingsStore = settingsStore, - cacheStore = cacheStore, - transferRepo = transferRepo, - clock = clock, - boltzService = boltzService, - currencyRepo = currencyRepo, - ) - - quoteOrder(order, viewModel) - - viewModel.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - runCurrent() - advanceTimeBy(120.seconds.inWholeMilliseconds + 1) - runCurrent() - advanceUntilIdle() - - verify(hwWalletRepo).disconnectStaleSession(HARDWARE_WALLET_ID) - verify(cacheStore, never()).addPaidOrder(any(), any()) - } finally { - Dispatchers.resetMain() - } - } - - @Test - fun `onTransferToSpendingHwConfirm does not fund order when user cancels on device`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())) - .thenReturn(Result.failure(TrezorException.UserCancelled())) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - verify(cacheStore, never()).addPaidOrder(any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm shows unlock prompt when device is busy`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())) - .thenReturn(Result.failure(AppError(TrezorException.DeviceBusy()))) - whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) - whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - toastJob.cancel() - - assertEquals(1, toasts.size) - assertEquals(Toast.ToastType.INFO, toasts.single().type) - assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) - verify(cacheStore, never()).addPaidOrder(any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm shows reconnect prompt for firmware error response`() = test { - val order = previewBtOrder() - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())) - .thenReturn(Result.failure(AppError("Device error (code 99): Firmware error"))) - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_title)) - .thenReturn(RECONNECT_TITLE) - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_description)) - .thenReturn(RECONNECT_DESCRIPTION) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - toastJob.cancel() - - assertEquals(1, toasts.size) - assertEquals(Toast.ToastType.ERROR, toasts.single().type) - assertEquals(RECONNECT_TITLE, toasts.single().title) - assertEquals(RECONNECT_DESCRIPTION, toasts.single().description) - verify(cacheStore, never()).addPaidOrder(any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm shows connection warning when composition times out`() = test { - val order = previewBtOrder() - val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())) - .thenReturn(Result.failure(timeout)) - whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) - whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - toastJob.cancel() - - assertEquals(1, toasts.size) - assertEquals(Toast.ToastType.WARNING, toasts.single().type) - assertEquals(CONNECTION_ISSUE_TITLE, toasts.single().title) - assertEquals(CONNECTION_ISSUE_DESCRIPTION, toasts.single().description) - verify(hwWalletRepo, never()).signFunding(any(), any()) - verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - verify(cacheStore, never()).addPaidOrder(any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm shows unlock prompt when device is busy during reconnect`() = test { - val order = previewBtOrder() - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.failure(AppError(TrezorException.DeviceBusy()))) - whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) - whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - toastJob.cancel() - - assertEquals(1, toasts.size) - assertEquals(Toast.ToastType.INFO, toasts.single().type) - assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) - verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm does not toast when user cancels during reconnect`() = test { - val order = previewBtOrder() - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.failure(TrezorException.UserCancelled())) - whenever(hwWalletRepo.isKnownBluetoothDevice(HARDWARE_WALLET_ID)).thenReturn(false) - whenever( - context.getString(R.string.lightning__transfer_hw__reconnect_error_title) - ).thenReturn("reconnect title") - whenever( - context.getString(R.string.lightning__transfer_hw__reconnect_error_description) - ).thenReturn("reconnect body") - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - toastJob.cancel() - - assertTrue(toasts.isEmpty()) - verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm retries broadcast without signing again`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signed = signedFunding(funding) - val broadcast = HwFundingBroadcastResult( - txId = TXID, - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE, - totalSpent = order.feeSat + MINING_FEE, - ) - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)) - .thenReturn( - Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed"))), - Result.success(broadcast), - ) - whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) - whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) - assertEquals(Toast.ToastType.WARNING, toasts.single().type) - assertEquals(CONNECTION_ISSUE_TITLE, toasts.single().title) - verify(cacheStore, never()).addPaidOrder(any(), any()) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - toastJob.cancel() - - assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) - verify(hwWalletRepo, times(1)).ensureConnected(HARDWARE_WALLET_ID) - verify(hwWalletRepo, times(1)).composeFundingTransaction(any(), any(), any(), any()) - verify(hwWalletRepo, times(1)).signFunding(HARDWARE_WALLET_ID, funding) - verify(hwWalletRepo, times(2)).broadcastFunding(signed) - verify(cacheStore).addPaidOrder(order.id, TXID) - } - - @Test - fun `onTransferToSpendingHwConfirm signs again when a new quote lands on another order address`() = test { - val order = previewBtOrder() - val reorder = order.copy( - id = "order-new", - payment = requireNotNull(order.payment).copy( - onchain = requireNotNull(order.payment?.onchain).copy(address = "bc1qnewdestination"), - ), - ) - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signed = signedFunding(funding) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)) - .thenReturn(Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed")))) - whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) - whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - sut.onSpendingAdvancedContinue((order.lspBalanceSat * 2u).toLong()) - advanceUntilIdle() - whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(reorder)) - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - verify(hwWalletRepo, times(2)).signFunding(HARDWARE_WALLET_ID, funding) - verify(hwWalletRepo).composeFundingTransaction( - HARDWARE_WALLET_ID, - "bc1qnewdestination", - reorder.feeSat, - FEE_RATE, - ) - verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) - } - - @Test - fun `cancelHardwareTransfer keeps tracking after signing completes`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signed = signedFunding(funding) - val broadcast = HwFundingBroadcastResult( - txId = TXID, - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE, - totalSpent = order.feeSat + MINING_FEE, - ) - val broadcastResult = CompletableDeferred>() - var hwTxSignedEmitted = false - backgroundScope.launch { - sut.transferEffects.collect { effect -> - if (effect is TransferEffect.OnHwTxSigned) { - hwTxSignedEmitted = true - } - } - } - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)).doSuspendableAnswer { broadcastResult.await() } - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - runCurrent() - assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) - - sut.cancelHardwareTransfer() - broadcastResult.complete(Result.success(broadcast)) - advanceUntilIdle() - - assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) - assertTrue(hwTxSignedEmitted) - verify(cacheStore).addPaidOrder(order.id, TXID) - verify(transferRepo).createTransfer( - eq(TransferType.TO_SPENDING), - eq(order.clientBalanceSat.toLong()), - isNull(), - eq(TXID), - eq(order.id), - isNull(), - isNull(), - isNull(), - ) - } - - @Test - fun `onTransferToSpendingHwConfirm retries core-derived txid after bookkeeping fails`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signed = signedFunding(funding) - val broadcast = HwFundingBroadcastResult( - txId = TXID, - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE, - totalSpent = order.feeSat + MINING_FEE, - ) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) - var bookkeepingAttempts = 0 - whenever(cacheStore.addPaidOrder(order.id, TXID)).thenAnswer { - if (bookkeepingAttempts++ == 0) throw AppError("cache failed") - Unit - } - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) - verify(hwWalletRepo).broadcastFunding(signed) - verify(transferRepo, never()).createTransfer( - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - ) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) - verify(hwWalletRepo, times(1)).signFunding(HARDWARE_WALLET_ID, funding) - verify(hwWalletRepo, times(2)).broadcastFunding(signed) - verify(cacheStore, times(2)).addPaidOrder(order.id, TXID) - verify(transferRepo).createPendingToSpendingActivity( - order, - TXID, - MINING_FEE, - FEE_RATE, - HARDWARE_WALLET_ID, - ) - } - - @Test - fun `onTransferToSpendingHwConfirm keeps signed transaction when broadcast times out`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signed = signedFunding(funding) - val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException - val toasts = mutableListOf() - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.failure(timeout)) - whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) - whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - toastJob.cancel() - - assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) - assertEquals(Toast.ToastType.WARNING, toasts.single().type) - verify(hwWalletRepo, never()).disconnectStaleSession(any()) - verify(cacheStore, never()).addPaidOrder(any(), any()) - } - - @Test - fun `onTransferToSpendingHwConfirm clears signed transaction after permanent broadcast failure`() = test { - val order = previewBtOrder() - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, + sut = TransferViewModel( + context = context, + lightningRepo = lightningRepo, + walletRepo = walletRepo, + transferRepo = transferRepo, + boltzService = boltzService, + clock = clock, ) - val signed = signedFunding(funding) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.failure(AppError("invalid transaction"))) - - quoteOrder(order) - - sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) - advanceUntilIdle() - - assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) - verify(cacheStore, never()).addPaidOrder(any(), any()) } @Test @@ -3616,295 +348,8 @@ class TransferViewModelTest : BaseUnitTest() { timeoutBlockHeight = 800uL, ) - private fun signedFunding( - funding: HwFundingTransaction, - feeRate: ULong = FEE_RATE, - ) = HwFundingSignedTx( - serializedTx = "rawtx", - miningFeeSats = funding.miningFeeSats, - feeRate = feeRate, - totalSpent = funding.totalSpent, - ) - - private fun connectedHardwareDevice() = HwConnectedDevice(vendor = HwWalletVendor.TREZOR, id = "dev1") - - private fun hwWallet(walletId: String, connected: Boolean) = HwWallet( - id = walletId, - name = "Trezor", - model = "Safe 3", - transportType = TransportType.USB, - isConnected = connected, - balanceSats = 0uL, - activities = persistentListOf(), - deviceIds = persistentSetOf("dev1"), - ) - - private fun liquidityOptions(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( - defaultLspBalanceSat = LSP_BALANCE, - minLspBalanceSat = LSP_BALANCE, - maxLspBalanceSat = 0uL, - maxClientBalanceSat = maxClientBalanceSat, - ) - - private fun stubFeeResponse(lspFees: ULong): IBtEstimateFeeResponse2 = mock().also { - whenever(it.feeSat).thenReturn(lspFees) - whenever(it.networkFeeSat).thenReturn(lspFees) - whenever(it.serviceFeeSat).thenReturn(0uL) - } - - private fun advancedLiquidityOptions(maxLspBalanceSat: ULong) = ChannelLiquidityOptions( - defaultLspBalanceSat = 100_000uL, - minLspBalanceSat = 50_000uL, - maxLspBalanceSat = maxLspBalanceSat, - maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE, - ) - - /** Prices an order at a flat 1_000 plus 1% of the receiving capacity, as the LSP charges both sides. */ - private suspend fun stubCapacityPricedFees() { - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).doSuspendableAnswer { invocation -> - // ULong params are erased to long across the mock boundary - val capacity = invocation.getArgument(1).toULong() - Result.success(stubFeeResponse(1_000uL + capacity / 100uL)) - } - } - - private fun liquidityOptionsForCreate(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( - defaultLspBalanceSat = LSP_BALANCE, - minLspBalanceSat = LSP_BALANCE, - maxLspBalanceSat = LSP_BALANCE, - maxClientBalanceSat = maxClientBalanceSat, - ) - - private fun btInfo(lspMaxClientBalance: ULong): IBtInfo { - val options = mock() - whenever(options.maxClientBalanceSat).thenReturn(lspMaxClientBalance) - return mock().also { whenever(it.options).thenReturn(options) } - } - - private suspend fun stubHwFundingAccount(balanceSats: ULong) { - whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)).thenReturn( - Result.success( - HwFundingAccount.Trezor( - xpub = XPUB, - addressType = HwFundingAddressType.NATIVE_SEGWIT, - balanceSats = balanceSats, - ), - ), - ) - } - - private suspend fun verifySendOnChain(sats: ULong, count: Int) { - verify(lightningRepo, times(count)).sendOnChain( - address = any(), - sats = eq(sats), - speed = any(), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = any(), - channelId = anyOrNull(), - isMaxAmount = any(), - tags = any(), - beforeSendAttempt = any(), - onBroadcast = any(), - requestId = anyOrNull(), - orderId = anyOrNull(), - transferContext = anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - } - - private suspend fun stubSingleUtxoFunding(miningFee: ULong) { - whenever { - lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) - }.thenReturn(Result.success(listOf(stubUtxo(110_000uL)))) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(miningFee)) - } - - private fun stubFeesChangedStrings() { - whenever(context.getString(R.string.lightning__spending_confirm__fees_changed_title)) - .thenReturn(FEES_CHANGED_TITLE) - whenever(context.getString(R.string.lightning__spending_confirm__fees_changed_service)) - .thenReturn(FEES_CHANGED_SERVICE) - whenever(context.getString(R.string.lightning__spending_confirm__fees_changed_network)) - .thenReturn(FEES_CHANGED_NETWORK) - } - - private suspend fun TestScope.triggerNetworkFeesChanged(): List { - val order = spendingOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 110_000uL) - stubSingleUtxoFunding(miningFee = 1_000uL) - stubSendOnChain() - stubFeesChangedStrings() - val toasts = collectToasts() - quoteOrder(order) - prepareConfirm() - stubSingleUtxoFunding(miningFee = 2_500uL) - - sut.onTransferToSpendingConfirm() - advanceUntilIdle() - - verifySendOnChain(sats = order.feeSat, count = 0) - return toasts - } - - private fun TestScope.collectToasts(): List { - val toasts = mutableListOf() - backgroundScope.launch { ToastEventBus.events.collect { toasts.add(it) } } - return toasts - } - - private fun assertFeesChangedToast(toasts: List, body: String, delta: ULong) { - assertEquals(1, toasts.size) - val toast = toasts.single() - assertEquals(Toast.ToastType.INFO, toast.type) - assertEquals(FEES_CHANGED_TITLE, toast.title) - assertFalse(toast.description.orEmpty().contains("{amount}")) - assertEquals(body.replace("{amount}", "$BITCOIN_SYMBOL ${delta.formatToModernDisplay()}"), toast.description) - } - - private fun TestScope.prepareConfirm() { - sut.prepareSpendingConfirmFunding() - advanceUntilIdle() - } - - private suspend fun TestScope.quoteOrder(order: IBtOrder, viewModel: TransferViewModel = sut) { - whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( - Result.success( - ChannelLiquidityOptions( - defaultLspBalanceSat = order.lspBalanceSat, - minLspBalanceSat = order.lspBalanceSat, - maxLspBalanceSat = order.lspBalanceSat, - maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE, - ), - ), - ) - val response = feeResponseFor(order) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) - viewModel.onConfirmAmount(order.clientBalanceSat.toLong()) - advanceUntilIdle() - } - - private fun feeResponseFor(order: IBtOrder): IBtEstimateFeeResponse2 = mock().also { - whenever(it.feeSat).thenReturn(order.networkFeeSat.safe() + order.serviceFeeSat.safe()) - whenever(it.networkFeeSat).thenReturn(order.networkFeeSat) - whenever(it.serviceFeeSat).thenReturn(order.serviceFeeSat) - } - - private fun spendingOrder(feeSat: ULong): IBtOrder = previewBtOrder( - feeSat = feeSat, - clientBalanceSat = feeSat.safe() - (NETWORK_FEE.safe() + SERVICE_FEE.safe()).safe(), - networkFeeSat = NETWORK_FEE, - serviceFeeSat = SERVICE_FEE, - ) - - private suspend fun stubSpendableBalances(spendable: ULong) { - val balances = BalanceDetails( - totalOnchainBalanceSats = spendable, - spendableOnchainBalanceSats = spendable, - totalAnchorChannelsReserveSats = 0u, - totalLightningBalanceSats = 0u, - lightningBalances = emptyList(), - pendingBalancesFromChannelClosures = emptyList(), - ) - whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) - } - - private suspend fun stubHwFundingSuccess(order: IBtOrder) { - val funding = HwFundingTransaction( - psbt = "psbt", - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE.toFloat(), - totalSpent = order.feeSat + MINING_FEE, - satsPerVByte = FEE_RATE, - ) - val signed = signedFunding(funding) - whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) - .thenReturn(Result.success(connectedHardwareDevice())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn( - Result.success( - HwFundingBroadcastResult( - txId = TXID, - miningFeeSats = MINING_FEE, - feeRate = FEE_RATE, - totalSpent = order.feeSat + MINING_FEE, - ), - ), - ) - } - - private fun stubUtxo(valueSats: ULong): SpendableUtxo = SpendableUtxo( - outpoint = OutPoint(txid = "stub-utxo-txid", vout = 0u), - valueSats = valueSats, - ) - - private suspend fun stubSendOnChain( - result: Result = Result.success(TXID), - nextResult: Result? = null, - ) { - val stubbing = whenever( - lightningRepo.sendOnChain( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - payerIdentity = anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ), - ) - fun typed(value: Result) = value.map { OnchainSendOutcome.Accepted(it) } - stubbing.thenReturn(typed(result)) - nextResult?.let { stubbing.thenReturn(typed(it)) } - } - private companion object { - const val ON_CHAIN_BALANCE = 10_000_000uL - const val LSP_MAX_CLIENT_BALANCE = 1_766_193uL - const val OPTION_MAX_CLIENT_BALANCE = 1_687_598uL - const val LSP_BALANCE = 252_368uL - const val ADVANCED_CLIENT_BALANCE = 100_000uL - const val ADVANCED_BUDGET = 110_000uL - const val FEES_CHANGED_TITLE = "Fees changed" - const val FEES_CHANGED_SERVICE = "Fees are {amount} higher now that your order is created." - const val FEES_CHANGED_NETWORK = "Fees are {amount} higher because fee rates changed." - const val NETWORK_FEE = 2_112uL - const val SERVICE_FEE = 286uL - const val LSP_FEE = 2_398uL // NETWORK_FEE + SERVICE_FEE - const val DEVICE_BUSY_MESSAGE = "Your Trezor is busy. Unlock it on the device, then try again." - const val CONNECTION_ISSUE_TITLE = "Internet Connectivity Issues" - const val CONNECTION_ISSUE_DESCRIPTION = "Please check your connection." - const val CONNECT_TITLE = "Connect Device" - const val CONNECT_DESCRIPTION = "Check the hardware device and try again." - const val WALLET_MISMATCH = "This device holds a different wallet." - const val HARDWARE_WALLET_ID = "hardware-wallet" - const val WALLET_ADDRESS = "bcrt1qwalletaddress" - const val PASSPHRASE_MISMATCH = "That passphrase opens a different wallet." - const val RECONNECT_TITLE = "Reconnect Hardware Device" - const val RECONNECT_DESCRIPTION = "Please reconnect your hardware device." - const val XPUB = "zpub-test" const val TXID = "tx-abc" - const val FEE_RATE = 2uL - const val FALLBACK_FEE_RATE = 3uL - const val MINING_FEE = 1_250uL const val SPENDABLE_LN = 150_000uL const val REQUESTED_SAT = 200_000uL const val SWAP_MIN = 25_000uL diff --git a/changelog.d/next/1696.changed.md b/changelog.d/next/1696.changed.md index 3020a38b33..bb0292e268 100644 --- a/changelog.d/next/1696.changed.md +++ b/changelog.d/next/1696.changed.md @@ -1 +1 @@ -Transfers to spending now point to Manual Setup, since Bitkit no longer opens Lightning connections automatically, and a forced close of a Blocktank connection no longer leaves funds stuck. +Blocktank no longer sells Lightning connections, so transfers to spending and receiving beyond your spending capacity now point to Manual Setup, and a forced close of a Blocktank connection no longer leaves funds stuck. diff --git a/docs/receive-liquidity.md b/docs/receive-liquidity.md index b3fcaa3c45..fcf0e5fd37 100644 --- a/docs/receive-liquidity.md +++ b/docs/receive-liquidity.md @@ -1,6 +1,8 @@ # Receive Liquidity Behavior -This document describes how the receive flow decides whether to show a normal Lightning invoice or send the user into CJIT liquidity setup. +This document describes how the receive flow decides whether to show a normal Lightning invoice. Blocktank no longer sells +Lightning channels, so the app never creates a CJIT: when receiving needs more inbound capacity than the user has, it shows +the Transfer Unavailable screen, which leads to Manual Setup. ## Cases @@ -8,14 +10,13 @@ This document describes how the receive flow decides whether to show a normal Li - A new receive session starts from a fresh tab state. - If Auto is available, the default tab is Auto. - If Auto is unavailable, the default tab is Savings. - - Temporary receive-session state, such as the selected tab, nested navigation, pending CJIT details, and CJIT invoice QR state, must not - survive closing and reopening Receive. + - Temporary receive-session state, such as the selected tab and nested navigation, must not survive closing and + reopening Receive. - Editing from Savings or Auto: - Editing sets the amount for the receive request. - If the edited amount can be received over Lightning, the regenerated Spending invoice also includes that amount. - - If the edited amount cannot be received over Lightning, Auto falls back to the Savings tab and shows the onchain QR instead of routing to CJIT. - - The edit flow does not create CJIT or route to CJIT amount entry. + - If the edited amount cannot be received over Lightning, Auto falls back to the Savings tab and shows the onchain QR. - Editing from a hardware receive tab: - Editing sets the amount for the hardware/onchain receive request. @@ -25,51 +26,29 @@ This document describes how the receive flow decides whether to show a normal Li - Lightning receive unavailable because there is no ready channel or ready inbound liquidity is `0`: - No Lightning invoice is created. - The normal QR remains Savings/onchain only. - - The Spending tab shows CJIT onboarding. - - Tapping receive spending routes to CJIT amount entry, or the CJIT geo-block screen when geo-blocked. - - Editing from Savings or Auto updates the receive amount and returns to the normal QR; it does not create or route to CJIT. - - When a channel already exists, later CJIT confirmation and learn-more screens use additional-liquidity copy. + - The Spending tab shows the Lightning onboarding view; tapping receive Lightning funds opens the Transfer Unavailable + screen. + - Editing from Savings or Auto updates the receive amount and returns to the normal QR. - Ready channel, inbound liquidity greater than `0`, zero/variable amount: - - A Lightning invoice is allowed. - - A zero/variable Lightning invoice is allowed when inbound liquidity is greater than `0`, even though the sender could later choose an amount above the available inbound capacity. + - A Lightning invoice is allowed, even though the sender could later choose an amount above the available inbound + capacity. - Ready channel, fixed amount less than or equal to inbound liquidity: - A normal BOLT11 invoice is created. - The unified QR includes Lightning. - The Spending tab shows the normal Lightning invoice. -- Ready channel, fixed amount greater than inbound liquidity but below CJIT minimum: +- Ready channel, fixed amount greater than inbound liquidity: - A normal Lightning invoice is not shown. - - Editing from Spending routes to CJIT amount entry. - - The user must choose at least the minimum CJIT amount. + - Editing from Spending opens the Transfer Unavailable screen. - Editing from Savings or Auto returns to the normal QR with Savings/onchain only. -- Ready channel, fixed amount greater than inbound liquidity and at or above CJIT minimum: - - If editing from Spending and the amount can be backed by a CJIT channel without exceeding Blocktank's maximum channel size, the edit flow creates additional CJIT. - - The user gets CJIT confirmation and then a CJIT Lightning invoice QR. - - The CJIT Lightning invoice is an invoice to the LSP and must be shown as Spending-only, not as Auto/unified receive. - - Editing from a CJIT Lightning invoice QR must replace the previously displayed CJIT invoice before showing the updated receive result, - because the previous LSP invoice is immutable. - - Re-applying an edit at the confirmed CJIT amount keeps the current CJIT invoice and refreshes on-chain metadata such as the note. - - Editing from a CJIT Lightning invoice QR to a different amount clears or replaces the current CJIT invoice before showing the updated receive result. - - The direct additional CJIT path must not regenerate the normal receive invoice before creating CJIT. - - If editing from Spending and the amount is too large for CJIT, or the maximum cannot be calculated, the edit flow routes to CJIT amount entry. - - The CJIT amount screen enforces the real maximum receivable amount, calculated from `invoiceSat + defaultLspBalance(invoiceSat) <= maxChannelSizeSat`. - - If Blocktank rejects additional CJIT because the node is already at its total capacity limit, the app explains that additional spending capacity is unavailable instead of showing the per-channel maximum. - - Editing from Savings or Auto returns to the normal QR with Savings/onchain only, except unchanged edits at the confirmed CJIT amount preserve the current CJIT invoice. - -- Geo-blocked and liquidity is needed: - - The flow routes to the CJIT geo-block screen. - - No CJIT invoice is created. - ## Invariants -- Auto tab availability and default tab selection are based on whether a normal Lightning invoice can be created for the current receive amount. +- Auto tab availability and default tab selection are based on whether a normal Lightning invoice can be created for the + current receive amount. - Ready channels alone do not imply Auto availability; fixed receive amounts must also fit within ready inbound liquidity. -- CJIT min and max limits are only needed when a Spending-origin edit needs additional inbound liquidity and the user is not geo-blocked. -- Before displaying a CJIT confirmation, the app must reject quotes where `feeSat >= invoiceSat` or - `channelSizeSat < invoiceSat - feeSat`. Invalid quotes must show a user-facing error and must never produce a negative receive amount. ## Platform Differences diff --git a/docs/screens-map.md b/docs/screens-map.md index bfde34c946..7dfb7bf5df 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -129,20 +129,12 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | -| FundingAdvancedScreen.kt | Manual Setup › Lightning Manual Setup | -| FundingScreen.kt | Lightning Onboarding From Suggestion Card › Lightning onboarding Fund | -| LiquidityScreen.kt | Transfer to Spending Balance From Savings (Channel Opening) › Transfer to Spending Receiving Capacity | +| FundingScreen.kt | `todo` | | SavingsAdvancedScreen.kt | Transfer to Savings (Channel Closure) › Transfer to savings select funds | | SavingsAvailabilityScreen.kt | Transfer to Savings (Channel Closure) › Transfer warning | | SavingsConfirmScreen.kt | Transfer to Savings (Channel Closure) › Transfer all funds to savings review | | SavingsIntroScreen.kt | Transfer to Savings (Channel Closure) › Transfer to savings | | SavingsProgressScreen.kt | Transfer to Savings (Channel Closure) › Transfer to savings progress 2 | -| SettingUpScreen.kt | Funds In Transfer › Transfer to Spending Progress 1 | -| SpendingAdvancedScreen.kt | Transfer to Spending Balance From Savings (Channel Opening) › Transfer to Spending Confirm Advanced | -| SpendingAmountScreen.kt | Transfer to Spending Balance From Savings (Channel Opening) › Transfer to Spending Input | -| SpendingConfirmScreen.kt | Transfer to Spending Balance From Savings (Channel Opening) › Transfer to Spending Confirm | -| SpendingIntroScreen.kt | Transfer to Spending Balance From Savings (Channel Opening) › Transfer to Spending Intro | -| TransferIntroScreen.kt | Lightning Onboarding From Suggestion Card › Lightning onboarding Intro | ## ui/screens/transfer/external @@ -158,9 +150,6 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | -| SpendingAmountHwScreen.kt | Transfer to Spending Balance From HW Device › Transfer to Spending Input HW | -| SpendingHwSignScreen.kt | Transfer to Spending Balance From HW Device › Transfer to Spending Confirm HW | -| SpendingHwSignedScreen.kt | Transfer to Spending Balance From HW Device › Transfer to Spending Confirmed HW | ## ui/screens/trezor @@ -191,11 +180,8 @@ 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 | -| LocationBlockScreen.kt | Send (Enter Manually) › CJIT Location Block | -| ReceiveAmountScreen.kt | Receive CJIT Onboarding › CJIT Enter amount | -| ReceiveConfirmScreen.kt | Send (Enter Manually) › CJIT Fee to connect | -| ReceiveLiquidityScreen.kt | Send (Hardware Device) (On-chain) › Liquidity fee | | ReceiveQrScreen.kt | Receive Lightning Active › Receive Auto (Unified QR) | +| TransferUnavailableScreen.kt | `todo` | ## ui/screens/wallets/send diff --git a/journeys/README.md b/journeys/README.md index 5647c3bef5..ed9f1eee4c 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -127,10 +127,8 @@ journey PR, which is what made this file conflict on every merge. | Controlled USDT quotes, provider history, funded submission and recovery | an explicitly configured local USDT gateway/RPC/bundler fixture with owned test accounts and funds; absent from the standard journey environment. Report these steps as blocked unless that setup and transfer authorization are supplied — [USDT QA](../docs/usdt-qa.md) | | On-chain funds and blocks on regtest | `./lsp` deposit and mine against the staging LSP — [Backend preconditions](#backend-preconditions) | | Several separate on-chain UTXOs to choose between | three or more `./lsp` deposits, each mined, so manual coin selection has inputs to list — [Backend preconditions](#backend-preconditions) | -| Lightning channels, CJIT orders and quoted maxima | the same staging LSP the dev flavor targets, plus its node as an external LN peer — [Backend preconditions](#backend-preconditions), [amount-limits](amount-limits/README.md) | +| Lightning channels | Manual Setup to the staging LSP node, which the dev flavor targets — [Backend preconditions](#backend-preconditions) | | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker` over the Bridge transport, with the USB attach intent injected by `adb`; USB enumeration, permission grants, the OS picker and BLE are not simulated — [hardware-wallet](hardware-wallet/README.md) | -| Push notifications to a backgrounded or killed app | an FCM push from a CJIT order paid through `./lsp`, read back with `adb shell dumpsys notification` — [cjit-notifications](cjit-notifications/README.md) | -| The OS notification-permission dialog | an API 33+ target, reset with `adb shell pm revoke to.bitkit.dev android.permission.POST_NOTIFICATIONS` — [notification-permission](notification-permission/README.md) | | An incoming Payment Request from a linked issuer | the fixture issuer, saved as a contact and linked as identities — [payment-requests](payment-requests/README.md) | | Two linked Bitkit wallets for a subscription lifecycle | a second Bitkit instance linked to the first, so a proposal can be reviewed and accepted — [subscriptions](subscriptions) | | A Pubky identity and a two-wallet marketplace purchase | the integration fixture runtime: Pubky testnet, Paykit Server, regtest bitcoind and Fulcrum — [pubky-marketplace](pubky-marketplace/README.md) | @@ -150,9 +148,8 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | Here | On iOS | | --- | --- | -| `cjit-notifications/cjit-foreground-service-notification.xml` | `cjit-background-notification.xml`, with the thousands-separator assertions dropped — the notification extension never formats an amount | -| `notification-permission/toggle-off-opens-system-settings.xml` | `toggle-off-and-system-settings-route.xml` — iOS does not deep link into system settings, so it asserts the real route in through Settings ▸ Notifications | | `hardware-wallet/usb-reconnect.xml` | `reconnect.xml` — over Bridge, since iOS cannot do WebUSB | +| `cjit-notifications/non-cjit-channel-no-payment-notification.xml` | not ported — iOS dropped its CJIT notification suite with the Blocktank sunset; this one checks Android's channel-ready notification on a Manual Setup channel | | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `activity/date-range-rapid-month-taps.xml` | not ported — iOS has no activity journey suite, and the rapid month tap behaviour was not checked there | | `coin-selection/manual-coin-selection.xml` | not ported — iOS has the screen (`SendUtxoSelectionView`) but no accessibility identifiers on it yet | @@ -170,7 +167,6 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `send/retry-original-dialog-style.xml` | not ported — iOS shows the two Retry original payment steps as native alerts | | `send/prepared-fee-recovery.md` | Same four manual fault cases and file on both platforms; the recipe records original-send versus replacement fee retention and iOS Max/identity-flow adaptations. These faults are not journey capabilities. | | `settings/electrum-server-error-toasts.xml` | not ported — iOS still shows one generic message for every manual Electrum connect failure | -| `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | | `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock; and iOS has no foreground-service path, so `confirmed-only-background-notification.xml` has no counterpart | | `backup/show-mnemonic-long-words.xml` | not ported — the long-word fit is an Android-only change (synonymdev/bitkit-android#633); whether iOS wraps long words at larger text sizes is unchecked | | `security/forgot-pin-nav-bar-inset.xml` | not ported — Android-only fix (synonymdev/bitkit-android#1572): the Android sheet host adds no navigation-bar inset, so each sheet screen pads itself. iOS sheets keep their content inside the bottom safe area | @@ -185,7 +181,6 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `deeplinks/screen-deeplink.xml`, `deeplinks/sheet-deeplink.xml` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | | `shop/gift-card-category-titles.xml` | not ported — iOS still hardcodes the category names, and its route in has no screen deeplink | -| `amount-limits/transfer-spending-preset-delete.xml` | not ported yet — the same fix shipped in synonymdev/bitkit-ios#289, so this one should port | | `app-update/critical-update-onboarding.xml` | not ported yet — iOS already blocks at the top level in `AppScene`, so the journey applies there once written | | `home/pull-to-refresh-rates.xml` | not ported — iOS does not refresh exchange rates on pull to refresh | | `home/timed-reminders-do-not-interrupt.xml` | paired draft in synonymdev/bitkit-ios#1013; the journey name and actions match, but iOS device verification is incomplete. Android resumes Home after permission prompts and background/foreground; iOS uses primary-screen lifecycle callbacks and explicit rechecks. | @@ -198,7 +193,6 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `lnurl/lnurl-pay-comment-note.xml` | not ported — bitkit-ios has not been checked for keeping the LNURL-pay comment on the activity | | `backup/confirm-mnemonic-clear-wrong-word.xml` | not ported — `BackupConfirmMnemonic.swift` clears only the last word by its chip, with no red-word tap | | `coin-selection/manual-coin-selection-load.xml` | not ported — iOS has `SendUtxoSelectionView` but no load error, retry or identifiers to assert on | -| `transfer/spending-confirm-amount-change.xml` | same file — both platforms show the same "Fees changed" info toast; iOS has no app-log step, and closing the received sheet re-sizes its Spending Confirm, so the lower amounts can show before the swipe | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | ### Running one on iOS diff --git a/journeys/amount-limits/README.md b/journeys/amount-limits/README.md index e89c47fd6b..cf68388bcf 100644 --- a/journeys/amount-limits/README.md +++ b/journeys/amount-limits/README.md @@ -2,8 +2,7 @@ These journeys exercise the "block numberpad input exceeding the max/available amount" behaviour added on `fix/block-input-over-max`. The same `AmountInputViewModel.setMaxAmount` + -`MaxExceeded` effect path backs all four amount-entry screens (Send, Transfer→Spending, -Receiving capacity, External node). +`MaxExceeded` effect path backs both amount-entry screens (Send, External node). ## What the feature does - Typing a digit that would push the amount **over the cap is rejected** — the display stays at @@ -20,16 +19,13 @@ Receiving capacity, External node). - Fund + mine via the `lsp` skill: `./lsp POST /regtest/chain/deposit '{"address":"","amountSat":100000}'` then `./lsp POST /regtest/chain/mine '{"count":3}'` and wait for the balance to sync. -2. **Transfer/Spending/Receiving-capacity flows need the node connected to the LSP** so a real - max can be quoted. On the Spending amount screen the max starts at `0` behind a spinner — - **wait for it to populate** before typing. -3. **External-node flow needs a reachable LN peer.** The staging LSP node works as the peer: +2. **External-node flow needs a reachable LN peer.** The staging LSP node works as the peer: id `028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc`, host `34.65.86.104`, port `9400` (from `./lsp GET /info`). ## Gotchas - **The cap can be lower than the visible "Available".** Fee/channel reserves mean e.g. Available - `99 890` but the spending max is `98 064`. Assert "does not exceed the **stated maximum**", + `99 890` but the max is `98 064`. Assert "does not exceed the **stated maximum**", not "Available". - **Do not assert Continue is disabled when over the max.** Because the input is *capped* (never left in an over-max state), the capped value is valid and Continue stays **enabled**. @@ -44,12 +40,6 @@ Receiving capacity, External node). - Send: screen `send_amount_screen`, field `SendNumberField`, available `available_balance`, max `SendAmountMax`, continue `ContinueAmount`; recipient `RecipientManual` / `RecipientInput` / `AddressContinue`; home Send button `Send`. -- Transfer→Spending: screen `SpendingAmount`, field `SpendingAmountNumberField`, - available `SpendingAmountAvailable`, 25% `SpendingAmountQuarter`, max `SpendingAmountMax`, - continue `SpendingAmountContinue`. -- Receiving capacity: screen `SpendingAdvanced`, field `SpendingAdvancedNumberField`, - min/default/max `SpendingAdvancedMin`/`SpendingAdvancedDefault`/`SpendingAdvancedMax`, - continue `SpendingAdvancedContinue`. - External: funding `FundManual`; connection `NodeIdInput`/`HostInput`/`PortInput`/`ExternalContinue`; amount screen `ExternalAmount`, field `ExternalAmountNumberField`, 25% `ExternalAmountQuarter`, max `ExternalAmountMax`, continue `ExternalAmountContinue`. diff --git a/journeys/amount-limits/transfer-spending-advanced-over-max.xml b/journeys/amount-limits/transfer-spending-advanced-over-max.xml deleted file mode 100644 index e0b1557fbe..0000000000 --- a/journeys/amount-limits/transfer-spending-advanced-over-max.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - Verifies the receiving capacity (advanced) number pad blocks input exceeding the maximum LSP - balance, shows the "Receiving Capacity Maximum" warning toast, and still allows deleting digits - while at the cap. - - Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and a running node - connected to the LSP. Start on the wallet home screen. The advanced screen is reached by first - setting a valid spending amount and continuing to the confirm screen. - - - Tap the Spending balance card on the home screen - Tap "Transfer from Savings" - If a transfer intro screen appears, tap "Get Started" - Wait until the spending amount screen (testTag "SpendingAmount") has loaded a positive maximum (testTag "SpendingAmountAvailable") - Tap "25%" (testTag "SpendingAmountQuarter") to set a valid amount within range - Tap Continue (testTag "SpendingAmountContinue") - On the confirm screen, tap "Advanced" - Verify the receiving capacity screen (testTag "SpendingAdvanced") is visible - Tap the "9" key (testTag "N9") nine times to enter a capacity far larger than the maximum allowed - Verify a "Receiving Capacity Maximum" warning toast appears - Verify the amount in the input field (testTag "SpendingAdvancedNumberField") does not exceed the maximum receiving capacity - Tap the delete key (testTag "NRemove") once - Verify the amount in the input field (testTag "SpendingAdvancedNumberField") decreased after the delete - - diff --git a/journeys/amount-limits/transfer-spending-over-max.xml b/journeys/amount-limits/transfer-spending-over-max.xml deleted file mode 100644 index 3e1a3a9544..0000000000 --- a/journeys/amount-limits/transfer-spending-over-max.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - Verifies the "Transfer to Spending" amount number pad blocks input exceeding the maximum allowed - transfer amount, shows the "Spending Balance Maximum" warning toast, and still allows deleting - digits while at the cap. - - Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and a running node - connected to the LSP (so a real max can be quoted). The max starts at 0 behind a spinner — wait - for it to populate. Note the cap may be lower than the visible "Available" due to fees. Start on - the wallet home screen. - - - Tap the Spending balance card on the home screen - Tap "Transfer from Savings" - If a transfer intro screen appears, tap "Get Started" - Verify the spending amount screen (testTag "SpendingAmount") is visible - Wait until the available/maximum amount (testTag "SpendingAmountAvailable") finishes loading and shows a positive value - Tap the "9" key (testTag "N9") nine times to enter an amount far larger than the maximum allowed - Verify a "Spending Balance Maximum" warning toast appears - Verify the amount in the input field (testTag "SpendingAmountNumberField") does not exceed the stated maximum - Tap the delete key (testTag "NRemove") once - Verify the amount in the input field (testTag "SpendingAmountNumberField") decreased after the delete - - diff --git a/journeys/amount-limits/transfer-spending-preset-delete.xml b/journeys/amount-limits/transfer-spending-preset-delete.xml deleted file mode 100644 index cda6165264..0000000000 --- a/journeys/amount-limits/transfer-spending-preset-delete.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - Verifies that after the "Transfer to Spending" amount is filled by a preset (25% or MAX) or by - switching the number pad unit from fiat, every delete press removes exactly one digit and - appended digits are accepted. The displayed amount keeps its sats grouping spaces, but the - number pad input underneath does not, so no press is spent on a space (synonymdev/bitkit-android#558). - - Precondition: onboarded dev wallet with the display unit set to modern Bitcoin (sats), primary - display Bitcoin, a POSITIVE on-chain Savings balance of at least 250 000 sats, LSP limits above - that, and a running node connected to the LSP. Start on the wallet home screen. No funds move: - the journey never taps Continue. - - Why 250 000: the number pad rejects any sats input above its max - (AmountInputViewModel.handleNumberPadInput, the guard comparing the new amount to maxAmount), - and that max is `maxAllowedToSend` — the Savings balance less the quoted LSP order fee, also - capped by the LSP's own max client balance (TransferViewModel, SpendingAmountScreen's - `setMaxAmount`). It is the value shown by "SpendingAmountUnit". Entering 10 fiat units and - switching back to sats gives about 10 000 sats at a BTC price near 100 000 fiat units, and the - append in the step below makes that about 100 001, so a smaller balance has the digit dropped - and the max toast shown instead, which false-fails the step. The guard in the fiat-entry step - below keeps that true if the price moves far enough for 10 fiat units to be worth more than a - tenth of the max. - - iOS counterpart: the same fix shipped in synonymdev/bitkit-ios#289, but the journey is not - ported to the iOS corpus yet. - - - Tap the Savings balance card (testTag "ActivitySavings") on the home screen - Tap "Transfer To Spending" (testTag "TransferToSpending") - If the spending intro screen appears, tap "Get Started" (testTag "SpendingIntro-button") - Verify the spending amount screen (testTag "SpendingAmount") is visible - Wait until the available amount (testTag "SpendingAmountUnit") finishes loading and shows a positive value - Tap the 25% button (testTag "SpendingAmountQuarter") - Verify the amount in the input field (testTag "SpendingAmountNumberField") shows a grouped value with a space, e.g. "78 777" - Tap the delete key (testTag "NRemove") repeatedly until the amount is 0, reading the amount after each press - Verify every delete press changed the amount, e.g. "78 777" → "7 877" → "787" → "78" → "7" → "0", with no press leaving the amount unchanged - Tap the number pad unit toggle (testTag "SpendingNumberPadUnit") so it shows the fiat currency - Tap "1" (testTag "N1") and "0" (testTag "N0") to enter 10 fiat units; if 10 fiat units are worth more than a tenth of the amount shown in "SpendingAmountUnit", tap only "1" (testTag "N1") instead, so the digit appended below still fits under that amount - Tap the number pad unit toggle (testTag "SpendingNumberPadUnit") so it shows "BITCOIN" - Verify the amount in the input field is a grouped sats value of at least 4 digits - Tap "1" (testTag "N1") - Verify the amount gained exactly one trailing digit "1", is not over the amount shown in "SpendingAmountUnit", and that no "Spending Balance Maximum" warning toast appeared - Tap the delete key (testTag "NRemove") repeatedly until the amount is 0, reading the amount after each press - Verify every delete press changed the amount - Tap the back button (testTag "NavigationBack") to leave without transferring - - diff --git a/journeys/backup-restore/restore-keeps-tags-and-closed-channels.xml b/journeys/backup-restore/restore-keeps-tags-and-closed-channels.xml index 518cc48928..2bfe8af14f 100644 --- a/journeys/backup-restore/restore-keeps-tags-and-closed-channels.xml +++ b/journeys/backup-restore/restore-keeps-tags-and-closed-channels.xml @@ -24,11 +24,9 @@ Tap the received activity row (testTag "ActivityShort-0") and verify its detail screen opens Tap "Tag" (testTag "ActivityTag"), enter the tag restoretag in the tag input (testTag "AddTagInput") and save it (testTag "AddTagSave") Verify the tag restoretag is listed on the activity detail screen (testTag "ActivityTags"), then go back to Home - Tap the SAVINGS balance tile (testTag "ActivitySavings"), then "Transfer To Spending" (testTag "TransferToSpending") - Pass any first-run intros on the way: transfer intro (testTag "TransferIntro-button"), the transfer funding option (testTag "FundTransfer") and the spending intro (testTag "SpendingIntro-button") - On the spending amount screen (testTag "SpendingAmount") enter 100 000 on the number pad ("N1" then "N0" five times) and tap Continue (testTag "SpendingAmountContinue") - Wait for the Blocktank order to be quoted, then confirm the transfer by swiping the confirm handle (testTag "GRAB") - Wait for the channel to open and leave the flow through its final button (testTag "TransferSuccess-button") + Open Settings, then the "Advanced" tab (testTag "Tab-advanced"), then "Lightning Connections" (testTag "Channels"), tap add connection (testTag "add_connection_button") and then "Manual Setup" (testTag "FundManual") + Enter the staging LSP node from `./lsp GET /info` into the node id (testTag "NodeIdInput"), host (testTag "HostInput") and port (testTag "PortInput") fields, continue, fund the connection with 100 000 sats and confirm it + Mine confirmations with `./lsp POST /regtest/chain/mine '{"count":3}'` and wait for the connection to open Open Settings, then the "Advanced" tab (testTag "Tab-advanced"), then "Lightning Connections" (testTag "Channels") Tap the open connection (testTag "Channel"), tap "Close Connection" (testTag "CloseConnection") and confirm (testTag "CloseConnectionButton") Verify the connection has left the open list, and that it is listed under "Show Closed & Failed" (testTag "ChannelsClosed") diff --git a/journeys/cjit-notifications/README.md b/journeys/cjit-notifications/README.md index fb3be3e23f..ad77780acb 100644 --- a/journeys/cjit-notifications/README.md +++ b/journeys/cjit-notifications/README.md @@ -1,66 +1,13 @@ -# CJIT channel-ready notification journeys +# Channel-ready notification journeys -These journeys reproduce the three notification issues raised in PR #787 (`fix/channelready-cjit`) -and verify the fixes. They exercise the `ChannelReady` event path across the two notification -producers: - -- **`WakeNodeWorker`** — wakes a killed/background node from an FCM push, opens the channel, and is - the *only* producer that should show a user-facing notification when nothing in-process is running. -- **`LightningNodeService`** (foreground service) + **`AppViewModel`** — handle the same - `ChannelReady` event when the app/service is alive, via `NotifyChannelReadyHandler`. - -## The bugs being reproduced - -1. **🟢 Missing thousands separators.** `WakeNodeWorker` built the amount as `"₿ $sats"` (raw), - so a CJIT receive showed `₿ 48064` instead of `₿ 48 064`. -2. **🟠 Double notification.** When the foreground service was running, a CJIT push produced - *two* notifications — one from `LightningNodeService` (`Received ₿ 48 064 ($30.79)`) and one - from `WakeNodeWorker` (`₿ 48064 / Via new channel`). -3. **🔴 Non-CJIT channel shows a payment notification (and multiplies).** Opening a regular - (non-CJIT) channel produced one-or-more `₿ 45000 / Via new channel` notifications even though no - payment was received. - -## The fix (what the journeys assert) - -- All received-payment notifications (foreground service, in-app, and the `WakeNodeWorker` push - path, for both regular payments and CJIT receives) are built by one shared - `ReceivedNotificationContent`, so they are identical: title "Payment Received", body - "Received ₿ <amount> (<fiat>)" — space-grouped BTC plus fiat, ordered by the primary - display setting (or hidden when notification details are off). The legacy bare "₿ <sats>" / - "Via new channel" push format is gone. [#1] -- `WakeNodeWorker` only emits a CJIT notification when the channel actually has a Blocktank CJIT - entry and the receive was not already recorded; a regular channel opening shows no payment - notification. [#3] -- `WakeNodeWorker` skips its own user-facing notification when an in-process handler covers the - event — i.e. when the app is in the foreground OR `LightningNodeService.isRunning` — leaving a - single notification. It still wakes the node and records the activity. [#2] +`non-cjit-channel-no-payment-notification.xml` reproduces issue #3 from PR #787 (`fix/channelready-cjit`): opening a +regular (non-CJIT) Lightning channel must not be reported as a received payment. Only a channel that carries a +Blocktank CJIT entry may produce a "Payment Received" notification, and a regular channel opening shows only the in-app +"Spending balance ready" toast. ## Mandatory setup -1. **Onboarded dev (regtest) wallet** with the node connected to the staging/regtest LSP. -2. **Create a CJIT entry and fund it** with the `blocktank-api` (`lsp`) skill so a real JIT channel - opens on payment: - - `./lsp POST /cjit '{"channelSizeSat":100000,"invoiceSat":50000,"invoiceDescription":"cjit","nodeId":"","channelExpiryWeeks":1}'` - - Pay the returned bolt11 invoice: `./lsp POST /regtest/lightning/pay '{"invoice":""}'` - - `./lsp POST /regtest/mine '{"count":3}'` if confirmation is needed. -3. **Background notifications** toggle (foreground service) is in Settings → Notifications; enabling - it keeps `LightningNodeService` alive in the background (`LightningNodeService.isRunning == true`). - -## Inspecting notifications (adb) - -Use the notification shade dump to assert what was posted: - -```sh -adb shell dumpsys notification --noredact | grep -A3 -i "to.bitkit.dev" -``` - -Look at the posted notifications' `android.title` / `android.text`. Count how many distinct -notifications Bitkit posted for a single channel opening. - -## Test tags / references - -- Foreground "Spending balance ready" toast: testTag `SpendingBalanceReadyToast`. -- Notification strings: `notification__received__title` ("Payment Received") and - `notification__received__body_amount` ("Received %s"). -- Amount grouping separator: `SATS_GROUPING_SEPARATOR` (a space) via `formatToModernDisplay`, - applied inside `ReceivedNotificationContent`. +1. **Onboarded dev (regtest) wallet** with a positive on-chain balance. +2. **A channel opened through Manual Setup** (Settings › Advanced › Lightning Connections › add connection › Manual + Setup) to the staging LSP node from `./lsp GET /info`, then mined with `./lsp POST /regtest/chain/mine`. +3. Read notifications back with `adb shell dumpsys notification --noredact | grep -A3 -i "to.bitkit"`. diff --git a/journeys/cjit-notifications/cjit-foreground-service-notification.xml b/journeys/cjit-notifications/cjit-foreground-service-notification.xml deleted file mode 100644 index 3d04d920af..0000000000 --- a/journeys/cjit-notifications/cjit-foreground-service-notification.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Reproduces PR #787 issues #1 (missing thousands separators) and #2 (double notification). - - With background notifications (the LightningNodeService foreground service) enabled and the app - in the background, a CJIT payment that opens a JIT channel must produce EXACTLY ONE "Payment - Received" notification, with a space-grouped amount (e.g. "Received ₿ 48 064 (...)"), NOT a raw - "₿ 48064" and NOT a second "Via new channel" notification from WakeNodeWorker. - - Precondition: onboarded regtest wallet connected to the LSP. A funded CJIT entry is ready to pay - (see README.md). Background notifications enabled in Settings → Notifications. No Lightning - channel open yet (this is the first, channel-opening payment). - - - Confirm background notifications are enabled (Settings → Notifications → background/foreground service toggle ON), then go back to the wallet home screen - Send the device to the background (press Home) so no Bitkit activity is in the foreground but the foreground service keeps running - Pay the CJIT invoice via the lsp skill: ./lsp POST /regtest/lightning/pay '{"invoice":"<bolt11>"}' - Wait ~5s for the ChannelReady event and notification delivery - Dump notifications: adb shell dumpsys notification --noredact | grep -A3 -i "to.bitkit" - Verify EXACTLY ONE Bitkit payment notification is posted for this channel opening (not two) - Verify its title is "Payment Received" and body is "Received ₿ <amount> (<fiat>)" with the amount space-grouped (e.g. "Received ₿ 48 064 ($30.79)") — NOT a bare "₿ 48064" - Verify there is NO separate "Via new channel" notification from WakeNodeWorker alongside it (the push path is deduped while the foreground service is running) - Tap the notification, bring the app to the foreground, and verify the received-transaction sheet shows the correct amount - - diff --git a/journeys/cjit-notifications/cjit-push-single-notification.xml b/journeys/cjit-notifications/cjit-push-single-notification.xml deleted file mode 100644 index 6039e99397..0000000000 --- a/journeys/cjit-notifications/cjit-push-single-notification.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - Reproduces PR #787 issue #2 on the pure push path. With the app fully killed (no foreground - activity and no foreground service), a CJIT payment must still surface a single payment - notification delivered by WakeNodeWorker — this is the wake-the-node-and-open-the-channel path, - so it must keep working. The notification must use the same rich content as the in-app one: - title "Payment Received", body "Received ₿ <amount> (<fiat>)" — NOT the old bare - "₿ <sats>" / "Via new channel". - - Precondition: onboarded regtest wallet connected to the LSP. A funded CJIT entry is ready to pay - (see README.md). Background notifications DISABLED (so no foreground service survives the kill), - and the app force-stopped so only the FCM push + WakeNodeWorker run. - - - Ensure background notifications are DISABLED in Settings → Notifications - Force-stop the app: adb shell am force-stop to.bitkit.dev - Pay the CJIT invoice via the lsp skill: ./lsp POST /regtest/lightning/pay '{"invoice":"<bolt11>"}' - Wait up to ~30s for the FCM push to wake the node and deliver the notification - Dump notifications: adb shell dumpsys notification --noredact | grep -A3 -i "to.bitkit" - Verify EXACTLY ONE Bitkit notification is posted for this CJIT payment - Verify the title is "Payment Received" and the body is "Received ₿ <amount> (<fiat>)" with the amount space-grouped (e.g. "Received ₿ 48 064 ($30.79)") — NOT a bare "₿ 48064" and NOT "Via new channel" - Tap the notification to open the app and verify the received amount in the activity list matches - - diff --git a/journeys/cjit-notifications/non-cjit-channel-no-payment-notification.xml b/journeys/cjit-notifications/non-cjit-channel-no-payment-notification.xml index 9e6777d95f..d620cd92b8 100644 --- a/journeys/cjit-notifications/non-cjit-channel-no-payment-notification.xml +++ b/journeys/cjit-notifications/non-cjit-channel-no-payment-notification.xml @@ -4,13 +4,12 @@ reported as a received payment: no "Payment Received" notification, and never several duplicates. When the app is in the foreground the only feedback is the in-app "Spending balance ready" toast. - Precondition: onboarded regtest wallet connected to the LSP, with a positive on-chain balance to - fund the transfer. No CJIT entry is involved here — this is a manual/Transfer channel opening - (orderPaymentConfirmed), not a CJIT receive. + Precondition: onboarded regtest wallet with a positive on-chain balance to fund the channel. No + CJIT entry is involved here — this is a channel opened through Manual Setup, not a CJIT receive. Keep the app in the foreground on the wallet home screen - Open a regular spending channel via Transfer → Spending (fund some sats and confirm), OR open one through the lsp skill order flow + Open a regular spending channel through Settings › Advanced › Lightning Connections › add connection › Manual Setup (testTag "FundManual") to the staging LSP node from `./lsp GET /info`, funding it with some sats Mine confirmations if required: ./lsp POST /regtest/mine '{"count":3}' Wait for the ChannelReady event (~5-10s) Verify an in-app "Spending balance ready" toast appears (testTag "SpendingBalanceReadyToast") diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 2d4cfa3d8f..ae12fd1e29 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -76,15 +76,11 @@ the other three rely on, and `passphrase-settings-remove.xml` removes it again. | `connect-flow.xml` | Settings Add button → connect flow with an edited Label Funds → paired device count + name | | `settings-hardware-wallets.xml` | Payments count row, Hardware Wallets screen list, rename sheet, Add button sheet/back dismiss, per-row delete confirm + re-pair | | `detail-overview.xml` | Detail screen overview, Transfer placeholder when funded, activity, Remove confirm + forget | -| `transfer-to-spending.xml` | Happy-path transfer plus one scoped hardware Transfer activity | -| `transfer-to-spending-max-lsp-cap.xml` | MAX when Trezor balance is higher than remaining LSP headroom; verifies MAX uses AVAILABLE and reaches sign without insufficient funds | -| `transfer-to-spending-node-warmup.xml` | Transfer started during app/node warm-up; verifies loading recovers into the sign screen | | `send-onchain.xml` | Normal Send flow funded by Trezor: source selection, guarded preparation, device signing, broadcast, success, and activity | | `receive-onchain.xml` | Trezor Receive tab: current address and QR display plus exact on-device address verification | | `passphrase-pairing.xml` | Passphrase button on Paired → Enter Passphrase → Passphrase Funds Found; second home tile, own label, no passphrase in logs | | `passphrase-duplicate.xml` | Re-entering a watched passphrase reports "already added" and adds no tile | | `passphrase-settings-remove.xml` | Per-identity settings row, rename and delete; removing the hidden wallet keeps the device paired | -| `passphrase-transfer-to-spending.xml` | Signs with the live session, re-prompts after the session is dropped, refuses a wrong passphrase | Connect-flow testTags: `HardwareWalletSheet`, `HardwareWalletIntroScreen`, `HardwareWalletIntroCancel`, `HardwareWalletIntroContinue`, diff --git a/journeys/hardware-wallet/passphrase-transfer-to-spending.xml b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml deleted file mode 100644 index b3333bdc0a..0000000000 --- a/journeys/hardware-wallet/passphrase-transfer-to-spending.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - Drives Transfer To Spending from a passphrase (hidden) wallet. While the Trezor session that - holds the passphrase is still open the transfer signs straight away; after the session is - dropped Bitkit asks for the passphrase again, and a wrong one is refused instead of signing - from whichever wallet the device happens to have open. Requires the emulator started with - passphrase protection enabled, the hidden wallet from passphrase-pairing.xml paired, and its - native-segwit account holding spendable regtest funds. - - - - Launch the Bitkit app and go to the wallet home screen - - - Tap the hidden wallet tile ("Hidden Trezor") and verify its detail screen opens (testTag "HardwareWalletScreen") - - - Tap "Transfer To Spending" (testTag "HardwareTransferToSpending"), and if the first-run intro is shown tap "Get Started" - - - Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter"), then "Continue" (testTag "HardwareTransferAmountContinue") and wait for the Blocktank order - - - Verify the sign screen opens (testTag "HardwareTransferSign") titled "SIGN WITH YOUR DEVICE" - - - Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and verify no passphrase sheet appears, because the session opened at pairing still holds the hidden wallet - - - Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order, and verify the transaction signed screen appears (testTag "HardwareTransferSigned") - - - Wait for the Processing Payment screen, tap "Continue Using Bitkit", and verify the app returns to the wallet home screen - - - adb: adb shell am force-stop to.bitkit.dev - - - adb: adb shell monkey -p to.bitkit.dev -c android.intent.category.LAUNCHER 1 - - - Once the app is back on the home screen, open the hidden wallet tile and start Transfer To Spending again, setting an amount with the "25%" quick button and continuing to the sign screen - - - Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and verify the passphrase sheet opens (testTag "HwTransferPassphraseSheet"), because the session that held the passphrase is gone - - - Type a wrong passphrase "not-the-one" into the input (testTag "HwTransferPassphraseInput") and tap "Continue" (testTag "HwTransferPassphraseContinue") - - - Confirm on the emulated device for every account the passphrase session reads: the device - prompts once per address type and the call blocks until it is acknowledged. Either tap the - emulator UI or run: ../bitkit-docker/scripts/trezor-emulator send-json '{"type":"emulator-press-yes","id":1}' - - - Verify an error toast says the passphrase opens a different wallet, that no signing prompt was shown on the emulator, and that no new transfer appears in the activity list - - - Navigate to the wallet home screen and verify the hardware tile count is unchanged: the wallet the wrong passphrase opened must not be added - - - Reopen the sign screen, tap "Open Trezor Connect", type the correct passphrase "bitkit-hidden" and tap "Continue" - - - Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator, and verify the transaction signed screen appears (testTag "HardwareTransferSigned") - - - adb: adb shell "run-as to.bitkit.dev sh -c 'grep -rl -e bitkit-hidden -e not-the-one files/logs/ files/datastore/ || echo NO_PASSPHRASE_LEAK'" - - - Verify the previous command printed NO_PASSPHRASE_LEAK: neither the correct nor the rejected passphrase may be written to the logs or the datastore - - - diff --git a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml deleted file mode 100644 index a36e8485d6..0000000000 --- a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - Verifies that a funded hardware wallet whose balance is larger than the current - Blocktank/LSP headroom is capped by the transferable spending limit, not by the - device balance. Requires a paired Bridge emulator or physical Trezor with a hardware - balance larger than the available transfer limit, and at least one existing channel or - pending order consuming most of the regtest LSP cap. - - - - Launch the Bitkit app and go to the wallet home screen - - - Verify the hardware wallet tile shows a balance larger than the AVAILABLE amount expected in the transfer flow - - - Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen") - - - Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") - - - Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", and the AVAILABLE amount is lower than the hardware wallet balance because it is capped by LSP headroom - - - Tap the "MAX" quick button (testTag "HardwareTransferAmountMax") - - - Verify the amount field matches the AVAILABLE amount exactly and does not use the full hardware wallet balance - - - Tap "Continue" (testTag "HardwareTransferAmountContinue") and wait for the Blocktank order to be created - - - Verify the sign screen opens (testTag "HardwareTransferSign"), showing NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL, without an insufficient-funds toast - - - Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and approve every hardware-wallet prompt - - - Verify the transaction signed screen appears (testTag "HardwareTransferSigned") and then advances to Processing Payment / setting-up progress - - - diff --git a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml deleted file mode 100644 index 518131da38..0000000000 --- a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - Verifies that starting a hardware-wallet transfer while the Lightning node is still - warming up does not fail or strand the CTA in loading. The flow should show the normal - loading/progress UI, continue once the node reaches the needed state, and land on the - sign screen. Requires a paired and funded hardware wallet. - - - - adb: adb shell am force-stop to.bitkit.dev - - - adb: adb shell monkey -p to.bitkit.dev -c android.intent.category.LAUNCHER 1 - - - As soon as the wallet home screen is visible, tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles - - - Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") - - - Verify the transfer amount screen or loading/progress UI appears, and no reconnect, node-not-ready, or generic failure toast is shown while the node warms up - - - Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") if the amount screen is shown - - - Tap "Continue" (testTag "HardwareTransferAmountContinue") if the amount screen is shown, then wait for order creation and node warm-up to finish - - - Verify the sign screen opens (testTag "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", and the Continue/Open Trezor Connect CTA is no longer loading - - - Navigate back without signing so this journey only covers node warm-up behavior - - - diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml deleted file mode 100644 index a93a397e40..0000000000 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - Drives the watch-only Transfer To Spending flow for a paired Trezor: Amount -> Sign With - Your Device -> Transaction Signed -> Processing Payment, then verifies the new transfer is - represented once in the hardware wallet's Core activity scope. Requires a paired Bridge - emulator whose native-segwit account holds spendable regtest funds and at least one older - hardware receive row with a distinct amount. - - - - Launch the Bitkit app and go to the wallet home screen - - - Verify the recent activity list contains an older blue hardware received row with a distinct amount - - - Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen") - - - Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") - - - If the first-run Transfer To Spending intro is shown, tap "Get Started" - - - Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row, the 25% and MAX quick buttons, and a number pad - - - Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") to set a valid amount below the available limit - - - Tap "Continue" (testTag "HardwareTransferAmountContinue") and wait for the fee estimate - - - Verify the sign screen opens (testTag "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", showing the NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL cells, the Learn More and Advanced buttons, and the Trezor illustration - - - Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") to create the Blocktank order. If its fee is higher than the estimate, verify the fee cells refresh and tap "Open Trezor Connect" again; otherwise signing starts immediately - - - Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order - - - Verify the transaction signed screen appears (testTag "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration - - - Wait for the screen to auto-forward and verify the Processing Payment / setting-up progress screen appears - - - Tap "Continue Using Bitkit" and verify the app returns to the wallet home screen - - - Verify the recent activity list contains exactly one new blue hardware Transfer row with subtitle "From Savings", above the older blue received row - - - Tap the new blue Transfer row and verify its detail screen shows "TO SPENDING" - - - diff --git a/journeys/notification-permission/README.md b/journeys/notification-permission/README.md deleted file mode 100644 index 4b2faa4135..0000000000 --- a/journeys/notification-permission/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Notification-permission journeys - -These journeys exercise the notification-permission request triggered by the -"Set up in background" toggle that backs Bitkit's background-payments setup. - -The behaviour was changed on `fix/limit-system-notification-permission`: tapping the -toggle now goes through the shared `rememberRequestNotificationPermission` helper -(`ui/utils/RequestNotificationPermissions.kt`) instead of jumping straight to the -system notification settings. - -## What the fix does -- **Toggle OFF (permission already granted)**: re-requesting a granted permission is a - no-op, so tapping opens the **system notification settings** (`openNotificationSettings`) - where the user can actually turn notifications off — the behaviour these toggles had - before the refactor. -- **Toggle ON, Android 13+ (API 33, TIRAMISU)**: tapping launches the OS - `POST_NOTIFICATIONS` runtime permission dialog. Granting it flips the toggle to - checked; the result is persisted via `SettingsViewModel.setNotificationPreference`. -- **Toggle ON, pre-13 (API < 33)**: there is no runtime dialog, so it falls back to the - system notification settings. - -The same helper backs four entry points; these journeys cover the three the user can -reach directly: -- **Transfer → Spending confirm** (`SpendingConfirmScreen`) -- **Receive → CJIT confirm** (`ReceiveConfirmScreen`) -- **Receive → CJIT liquidity** (`ReceiveLiquidityScreen`, via "Learn more") - -## Mandatory setup -1. **Use an API 33+ device** to verify the runtime-dialog path. On API < 33 the dialog - never appears — only the system-settings fallback is exercised. -2. **Start from a fresh notification-permission state.** The OS only shows the - `POST_NOTIFICATIONS` dialog while the permission is in the "ask" state. Once granted - or denied it will not show again, and the journey will silently pass for the wrong - reason. Reset before each run: - `adb shell pm revoke to.bitkit.dev android.permission.POST_NOTIFICATIONS` - (or reinstall / clear app data). -3. **Node must be connected to the LSP (Blocktank).** Both the Transfer→Spending and - Receive→CJIT confirm screens need a real order quoted by Blocktank before the toggle - screen renders. With the hosted staging backend this is `api.stag0.blocktank.to`. -4. **Transfer→Spending also needs a positive on-chain Savings balance** so a real max can - be quoted. Fund + mine via the `blocktank-api:lsp` skill, then wait for the balance to - sync. - -## Gotchas -- **The permission dialog is one-shot** — see setup #2. Always revoke/reset first. -- **Blocktank must be reachable.** If `api.stag0.blocktank.to:443` is down, CJIT/order - creation hangs on a spinner at "Continue" and the confirm screen never appears, so the - toggle is unreachable. Verify the host first: - `curl -s -m 8 -o /dev/null -w '%{http_code}\n' https://api.stag0.blocktank.to/blocktank/api/v2/info` - (`000` = down). This is infra, not the toggle. -- The system permission dialog is **OS UI**, not Compose — locate its buttons with - `android screen --annotate` (text "Allow" / "Don't allow"), not `android layout` tags. -- On grant, the toggle reflects `notificationsGranted`; it only flips to checked once the - `ON_RESUME` re-check or the launcher callback fires. - -## Test tags -- Transfer→Spending toggle switch: `SpendingConfirmNotificationSwitch` -- Receive→CJIT confirm toggle switch: `ReceiveConfirmNotificationSwitch` -- Receive→CJIT liquidity toggle switch: `ReceiveLiquidityNotificationSwitch` -- Spending amount screen: `SpendingAmount`, continue `SpendingAmountContinue`, - available/max `SpendingAmountAvailable` / `SpendingAmountMax`. -- The toggle label on all screens is "Set up in background". diff --git a/journeys/notification-permission/receive-cjit-confirm-notification-toggle.xml b/journeys/notification-permission/receive-cjit-confirm-notification-toggle.xml deleted file mode 100644 index 46144c1745..0000000000 --- a/journeys/notification-permission/receive-cjit-confirm-notification-toggle.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - Verifies that the "Set up in background" toggle on the Receive → CJIT confirm screen - (ReceiveConfirmScreen) launches the Android POST_NOTIFICATIONS runtime permission - dialog on API 33+, and that granting it checks the toggle. - - Precondition: API 33+ onboarded dev wallet with the node connected to the LSP so a - CJIT order can be quoted, and POST_NOTIFICATIONS in the "ask" state (revoke or - reinstall first — the dialog is one-shot). Start on the wallet home screen. - - - Tap the "Receive" button on the home screen - Tap the "Spending" tab in the Receive sheet - Tap "Receive Lightning funds" - On the amount screen, enter an amount above the CJIT minimum (e.g. 100 000 sats) using the number pad - Tap "Continue" and wait for the CJIT order to be created and the confirm screen ("To set up your spending balance...") to appear - Verify the "Set up in background" toggle (testTag "ReceiveConfirmNotificationSwitch") is visible and unchecked - Tap the "Set up in background" toggle - Verify the Android system notification permission dialog appears (text like "Allow Bitkit to send you notifications?") - Tap "Allow" - Verify the "Set up in background" toggle is now checked - - diff --git a/journeys/notification-permission/receive-cjit-liquidity-notification-toggle.xml b/journeys/notification-permission/receive-cjit-liquidity-notification-toggle.xml deleted file mode 100644 index 1486c08a08..0000000000 --- a/journeys/notification-permission/receive-cjit-liquidity-notification-toggle.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Verifies that the "Set up in background" toggle on the Receive → CJIT liquidity screen - (ReceiveLiquidityScreen, reached via "Learn more" from the confirm screen) launches the - Android POST_NOTIFICATIONS runtime permission dialog on API 33+, and that granting it - checks the toggle. - - Precondition: API 33+ onboarded dev wallet with the node connected to the LSP so a CJIT - order can be quoted, and POST_NOTIFICATIONS in the "ask" state (revoke or reinstall - first — the dialog is one-shot). Start on the wallet home screen. - - - Tap the "Receive" button on the home screen - Tap the "Spending" tab in the Receive sheet - Tap "Receive Lightning funds" - On the amount screen, enter an amount above the CJIT minimum (e.g. 100 000 sats) using the number pad - Tap "Continue" and wait for the confirm screen to appear - Tap "Learn more" to open the liquidity screen - Verify the liquidity screen with the lightning channel and the "Set up in background" toggle (testTag "ReceiveLiquidityNotificationSwitch") is visible and unchecked - Tap the "Set up in background" toggle - Verify the Android system notification permission dialog appears (text like "Allow Bitkit to send you notifications?") - Tap "Allow" - Verify the "Set up in background" toggle is now checked - - diff --git a/journeys/notification-permission/toggle-off-opens-system-settings.xml b/journeys/notification-permission/toggle-off-opens-system-settings.xml deleted file mode 100644 index c90ca54c4f..0000000000 --- a/journeys/notification-permission/toggle-off-opens-system-settings.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - Verifies that when the "Set up in background" toggle is already ON (POST_NOTIFICATIONS - granted), tapping it opens the system notification settings instead of re-requesting the - permission (which would be a no-op). Uses the Receive → CJIT confirm screen, but the - behaviour is shared by the transfer and liquidity toggles. - - Precondition: API 33+ onboarded dev wallet with the node connected to the LSP so a CJIT - order can be quoted, and POST_NOTIFICATIONS ALREADY GRANTED so the toggle starts checked - (grant via: adb shell pm grant to.bitkit.dev android.permission.POST_NOTIFICATIONS). - Start on the wallet home screen. - - - Tap the "Receive" button on the home screen - Tap the "Spending" tab in the Receive sheet - Tap "Receive Lightning funds" - On the amount screen, enter an amount above the CJIT minimum (e.g. 100 000 sats) using the number pad - Tap "Continue" and wait for the confirm screen to appear - Verify the "Set up in background" toggle (testTag "ReceiveConfirmNotificationSwitch") is visible and checked - Tap the "Set up in background" toggle - Verify the Android system notification settings screen for Bitkit opens (no in-app navigation, no permission dialog) - Press back to return to Bitkit and verify the confirm screen is still shown - - diff --git a/journeys/notification-permission/transfer-spending-confirm-notification-toggle.xml b/journeys/notification-permission/transfer-spending-confirm-notification-toggle.xml deleted file mode 100644 index 71fde7c486..0000000000 --- a/journeys/notification-permission/transfer-spending-confirm-notification-toggle.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Verifies that the "Set up in background" toggle on the Transfer → Spending confirm - screen (SpendingConfirmScreen) launches the Android POST_NOTIFICATIONS runtime - permission dialog on API 33+, and that granting it checks the toggle. - - Precondition: API 33+ onboarded dev wallet with a POSITIVE on-chain Savings balance and - the node connected to the LSP (so a real max can be quoted), and POST_NOTIFICATIONS in - the "ask" state (revoke or reinstall first — the dialog is one-shot). The spending max - starts at 0 behind a spinner — wait for it to populate. Start on the wallet home screen. - - - Tap the Spending balance card on the home screen - Tap "Transfer from Savings" - If a transfer intro screen appears, tap "Get Started" - On the spending amount screen (testTag "SpendingAmount"), wait until the available/maximum amount (testTag "SpendingAmountAvailable") finishes loading and shows a positive value - Enter an amount within the maximum using the number pad - Tap continue (testTag "SpendingAmountContinue") and wait for the spending confirm screen to appear - Verify the "Set up in background" toggle (testTag "SpendingConfirmNotificationSwitch") is visible and unchecked - Tap the "Set up in background" toggle - Verify the Android system notification permission dialog appears (text like "Allow Bitkit to send you notifications?") - Tap "Allow" - Verify the "Set up in background" toggle is now checked - - diff --git a/journeys/receive/receive-tabs-wraparound.xml b/journeys/receive/receive-tabs-wraparound.xml index bd0a672eb5..0f9067e707 100644 --- a/journeys/receive/receive-tabs-wraparound.xml +++ b/journeys/receive/receive-tabs-wraparound.xml @@ -6,7 +6,7 @@ the Receive sheet, also when it is released above the sheet, and a tap on a QR still copies it. Precondition: onboarded dev wallet with USDT enabled (introductory sheet or Settings, General, - Payments), no open Spending channel, no CJIT invoice pending and no hardware wallet paired, so the + Payments), no open Spending channel and no hardware wallet paired, so the tabs are Savings, Spending and USDT; a usable channel adds the Auto tab. Start on the wallet home screen. The USDT address loads without funding; wait for the QR before swiping. diff --git a/journeys/receive/usdt-receive-swipe-tabs.xml b/journeys/receive/usdt-receive-swipe-tabs.xml index fea00c104a..dc465a5883 100644 --- a/journeys/receive/usdt-receive-swipe-tabs.xml +++ b/journeys/receive/usdt-receive-swipe-tabs.xml @@ -7,7 +7,7 @@ tooltip. Precondition: onboarded dev wallet with USDT enabled (introductory sheet or Settings, General, - Payments), no CJIT invoice pending, start on the wallet home screen. The USDT address loads + Payments), start on the wallet home screen. The USDT address loads without funding; wait for the QR before swiping. The copy shows as a tooltip over the QR for about two seconds, so a check for "no copy" reads a diff --git a/journeys/transfer/spending-confirm-amount-change.xml b/journeys/transfer/spending-confirm-amount-change.xml deleted file mode 100644 index 068923d04f..0000000000 --- a/journeys/transfer/spending-confirm-amount-change.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - Verifies that a transfer to spending never pays more than the total Spending Confirm shows - (synonymdev/bitkit-android#1361). The funding plan is rebuilt when the order is created at swipe. - A plan that costs the same or less pays on that swipe, at the rebuilt amount. A plan whose total is - higher pays nothing: the screen shows the new amounts and a "Fees changed" toast, and the next - swipe pays them. The higher-total case needs a spend of the wallet's own UTXO outside the app, which - the Capabilities table does not list, so unit tests and a manual test cover it. - - The amount changes through coin selection: Spending Confirm sizes the network fee for two inputs, - then a larger UTXO confirms before the swipe, so the plan rebuilt at swipe spends one input and - pays a lower network fee, at once. - - iOS differs in one step: it has no app-log step. - - Precondition: a freshly onboarded dev wallet on the staging regtest backend with no channel and - exactly two confirmed on-chain UTXOs of 150 000 sats each (two ./lsp deposits to the wallet's - savings address, each followed by a mine). Start on the wallet home screen. - - - Tap Receive (testTag "Receive"), tap the "Savings" receive tab (the tab row carries no test tag), tap "Show Details" (testTag "ShowDetails"), read the address from testTag "ReceiveOnchainAddress", then press back to the home screen - Tap the Savings balance card (testTag "ActivitySavings"), then "Transfer To Spending" (testTag "TransferToSpending") - If the spending intro appears, tap "Get Started" (testTag "SpendingIntro-button") - Enter 200 000 on the number pad (testTags "N2", "N0", "N0", "N0", "N0", "N0") and tap "Continue" (testTag "SpendingAmountContinue") - Verify the confirm screen is visible (testTag "SpendingConfirmMore") and wait until its network fee is shown - Note the amounts under "Network Fees" and "Total" - Run: adb logcat -c - Run in one command: ./lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":500000}' && ./lsp POST /regtest/chain/mine '{"count":1}' - Wait 30s for the wallet to sync the new UTXO - If the received sheet (testTag "ReceivedTransaction") is visible, tap its button (testTag "ReceivedTransactionButton") - Verify the confirm screen still shows the amounts noted before the deposit - Swipe the "Swipe To Transfer" handle (testTag "GRAB") fully to the right - Wait up to 90s for the setting up screen (testTag "LightningSettingUp") - Run: adb logcat -d -s APP:V | grep "BT confirm" - Verify the last "BT confirm" line shows a lower miningFee than the "Network Fees" amount noted, not the amount the screen showed - - diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml index 1e9b604ed9..e3fe769fee 100644 --- a/journeys/transfer/transfer-unavailable-sheet.xml +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -1,15 +1,15 @@ Verifies that Bitkit points users to Manual Setup now that Blocktank no longer sells Lightning channels. - Blocktank's geocheck answers 403, so the app is geo-blocked, and the Transfer To Spending buttons on the - wallet screens open the "Transfer Unavailable" sheet (testTag "TransferUnavailable") instead of the transfer flow. The + Every Transfer To Spending entry opens the "Transfer Unavailable" sheet (testTag "TransferUnavailable") at once, + with no network check first, and the transfer purchase flow no longer exists. The sheet shows "MANUAL SETUP REQUIRED" (testTag "TransferUnavailableTitle") and the buttons "Learn More" (testTag "TransferUnavailableLearnMore"), which opens https://blog.bitkit.to/ in the browser, and "Continue" (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning path shows the same sheet content. - Precondition: dev build with geoblocking on (the default; `GEO=false` turns it off), onboarded wallet with - a positive on-chain Savings balance and no channels. Start on the wallet home screen. + Precondition: dev build, onboarded wallet with a positive on-chain Savings balance and no channels. Start on + the wallet home screen. Tap the Savings balance card (testTag "ActivitySavings") and verify "Transfer To Spending" (testTag "TransferToSpending") is shown diff --git a/journeys/transfers/closed-channel-transfer-settles.xml b/journeys/transfers/closed-channel-transfer-settles.xml deleted file mode 100644 index 7b0daa4ee3..0000000000 --- a/journeys/transfers/closed-channel-transfer-settles.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - Covers issue #614. A pending Transfer to Spending keeps its "TRANSFER IN PROGRESS" banner across - an app restart while the node starts, and a transfer whose channel the LSP later closes does not - leave a banner behind or inflate the Spending balance. - - Precondition: onboarded dev wallet (staging regtest) with a positive on-chain Savings balance and - a running node connected to the LSP. Start on the wallet home screen. The LSP only opens the - channel after the order payment has one confirmation, so the transfer stays pending until a block - is mined. The dev build mines that block itself: the SettingUp screen auto-mines one block 5 s - after it appears on regtest, and leaving the screen cancels it. Tap "Continue Using Bitkit" within - those 5 s so the transfer is still pending for the restart steps; the block is then mined by hand - later in the journey. - - Transfer orders are zero-conf, so on an unmodified build the transfer settles as soon as the - channel is ready and the closed-channel settle path is not reached on its own. This journey then - checks that nothing is left behind after the close. Reaching the closed-channel path on a device - needs a temporary local change that skips the channel-ready settle; it was verified that way for - issue #614 and is covered by TransferRepoTest.kt. - - On-screen, this journey is a no-regression check: the banner, balance and close-sheet steps pass - on master too, because both versions settle the transfer when the zero-conf channel becomes ready. - The one thing that tells the two builds apart on a device is the app log. Only the fixed build - fetches LSP orders for still-pending transfers, so only it writes "orders for active transfers" - while the transfer is pending; master never calls the LSP there. The fixed build may also write - "Skipped syncing transfers because channels are unavailable" while the node is starting after the - restart, which master does not write either, but that line is not guaranteed on every run. - - The close moves the transfer amount back to Savings minus fees, so the wallet ends close to its - starting balances. - - - Note the Spending balance shown on the home screen (testTag "ActivitySpending") - Tap the Savings balance card (testTag "ActivitySavings") - Tap "Transfer To Spending" (testTag "TransferToSpending") - If the spending intro screen appears, tap its button (testTag "SpendingIntro-button") - Verify the spending amount screen (testTag "SpendingAmount") is visible - Wait until the available amount (testTag "SpendingAmountUnit") shows a positive value - Tap the "5" key (testTag "N5") enough times to enter an amount the LSP accepts (50000 sats is a safe value), then tap Continue (testTag "SpendingAmountContinue") - Swipe "Swipe To Transfer" to confirm - As soon as the setting up screen (testTag "LightningSettingUp") appears, tap "Continue Using Bitkit" (testTag "TransferSuccess-button") within 5 s, before the screen auto-mines a block; do not dump the layout first - Verify the purple "TRANSFER IN PROGRESS" banner is visible on the home screen (screenshot; the banner carries no testTag). If the banner is already gone, the auto-mine won the race: report the run as inconclusive and start over - Run adb shell am force-stop to.bitkit.dev, then launch the app again - Wait for the home screen and for the node to start (app log line "Node started") - Verify the purple "TRANSFER IN PROGRESS" banner is still visible (screenshot) - Read the order id from the app log: the active TO_SPENDING transfer's "lspOrderId" in the latest "Active transfers at block height" line - Verify the app log has a line ending in "orders for active transfers" written after the transfer was created; master never writes it, so its absence means the build under test does not carry the fix - Run ./lsp POST /regtest/chain/mine '{"count":1}' - Poll ./lsp GET /channels/<lspOrderId> until channel.state is "open", and note channel.fundingTx.id and channel.fundingTx.vout - Tap the OK button (testTag "TransferSuccess-button") if the Transfer Successful screen is shown; its label is randomized ("Great!", "Nice!", …) - Run ./lsp POST /regtest/channel/close '{"fundingTxId":"<fundingTx.id>","vout":<fundingTx.vout>,"forceCloseAfterSec":0}' - Run ./lsp POST /regtest/chain/mine '{"count":3}' - Wait for the "Connection Closed" sheet and tap OK (testTag "ConnectionClosedButton") - Verify no "TRANSFER IN PROGRESS" banner is visible on the home screen (screenshot) - Verify the Spending balance (testTag "ActivitySpending") matches the value noted in the first step - Verify the app log has a "ready, settled transfer:" line for the transfer (the build writes "Channel <channelId> ready, settled transfer: <id>", with no quotes around the ids), and that the latest "Active transfers at block height" line lists no TO_SPENDING transfer. The closed-channel settle lines ("Settled transfer '<id>' for closed channel …") only appear on a build that skips the channel-ready settle, so do not expect them here - - From 4eeb421b6d3a4eb7a359a78862074288e190bc3d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 18:57:27 +0100 Subject: [PATCH 3/6] feat: blocktank sunset --- .../main/java/to/bitkit/data/CacheStore.kt | 7 ----- .../main/java/to/bitkit/data/SettingsStore.kt | 3 -- .../to/bitkit/services/MigrationService.kt | 4 --- .../java/to/bitkit/data/AppCacheDataTest.kt | 22 -------------- .../to/bitkit/repositories/BackupRepoTest.kt | 29 ------------------- 5 files changed, 65 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 4f7bd9e0d5..ce58f67e03 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -185,7 +185,6 @@ data class AppCacheData( val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), val quickPayLedger: QuickPayLedger? = null, - val blocktankRefundAddress: BlocktankRefundAddress? = null, /** LNURL-pay comments by payment hash, kept until the sent payment's activity stores them. */ val pendingLightningMessages: Map = mapOf(), ) { @@ -203,12 +202,6 @@ data class AppCacheData( fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "") } -@Serializable -data class BlocktankRefundAddress( - val address: String, - val index: Long, -) - @Serializable data class CachedUsdtBalance( val address: String, diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index c74983d3d0..10430365ad 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -189,9 +189,7 @@ data class SettingsData( val selectedCurrency: String = "USD", val defaultTransactionSpeed: TransactionSpeed = TransactionSpeed.default(), val showEmptyBalanceView: Boolean = true, - val hasSeenSpendingIntro: Boolean = false, val hasSeenWidgetsIntro: Boolean = false, - val hasSeenTransferIntro: Boolean = false, val hasSeenSavingsIntro: Boolean = false, val hasSeenShopIntro: Boolean = false, val hasSeenProfileIntro: Boolean = false, @@ -213,7 +211,6 @@ data class SettingsData( val isQuickPayEnabled: Boolean = false, val quickPayAmount: Int = 5, val quickPayDailyLimitMultiplier: Int = 5, - val lightningSetupStep: Int = 0, val isPinEnabled: Boolean = false, val isBiometricEnabled: Boolean = false, val isPinForPaymentsEnabled: Boolean = false, diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 352e7f7951..5c495a1fba 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -1021,8 +1021,6 @@ class MigrationService internal constructor( isBiometricEnabled = settings.biometrics ?: current.isBiometricEnabled, quickPayIntroSeen = settings.quickpayIntroSeen ?: current.quickPayIntroSeen, hasSeenShopIntro = settings.shopIntroSeen ?: current.hasSeenShopIntro, - hasSeenTransferIntro = settings.transferIntroSeen ?: current.hasSeenTransferIntro, - hasSeenSpendingIntro = settings.spendingIntroSeen ?: current.hasSeenSpendingIntro, hasSeenSavingsIntro = settings.savingsIntroSeen ?: current.hasSeenSavingsIntro, ) } @@ -2261,8 +2259,6 @@ data class RNSettings( val hideBalanceOnOpen: Boolean? = null, val quickpayIntroSeen: Boolean? = null, val shopIntroSeen: Boolean? = null, - val transferIntroSeen: Boolean? = null, - val spendingIntroSeen: Boolean? = null, val savingsIntroSeen: Boolean? = null, val selectedAddressType: String? = null, val addressTypesToMonitor: List? = null, diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt index f539d78118..0f0a540ac7 100644 --- a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -1,7 +1,5 @@ package to.bitkit.data -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import org.junit.Test import to.bitkit.data.serializers.AppCacheSerializer import to.bitkit.di.json @@ -45,26 +43,6 @@ class AppCacheDataTest : BaseUnitTest() { assertEquals(LEGACY_BOLT11, cachedReceive.bolt11) assertEquals("", cachedReceive.bolt11PaymentHash) - assertNull(cachedReceive.blocktankRefundAddress) - } - - @Test - fun `Blocktank refund address uses the shared cross-platform cache shape`() { - val cache = AppCacheData( - blocktankRefundAddress = BlocktankRefundAddress( - address = "bcrt1qrefund", - index = 7, - ), - ) - - val encoded = json.encodeToString(cache) - val refund = json.parseToJsonElement(encoded).jsonObject - .getValue("blocktankRefundAddress").jsonObject - val decoded = json.decodeFromString(encoded) - - assertEquals("bcrt1qrefund", refund.getValue("address").jsonPrimitive.content) - assertEquals("7", refund.getValue("index").jsonPrimitive.content) - assertEquals(cache.blocktankRefundAddress, decoded.blocktankRefundAddress) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 5e192dbd06..936667e78d 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -36,7 +36,6 @@ import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.AppDb -import to.bitkit.data.BlocktankRefundAddress import to.bitkit.data.CacheStore import to.bitkit.data.HwWalletData import to.bitkit.data.HwWalletStore @@ -1513,34 +1512,6 @@ class BackupRepoTest : BaseUnitTest() { assertEquals(listOf(preActivityMetadata(), hardwareTagMetadata), payload.tagMetadata) } - @Test - fun `metadata backup carries the Blocktank refund address pointer`() = test { - val refundAddress = BlocktankRefundAddress(address = "bcrt1qrefund", index = 7) - cacheData.value = AppCacheData(blocktankRefundAddress = refundAddress) - stubMetadataBackupReads() - val dataCaptor = argumentCaptor() - - sut.triggerBackup(BackupCategory.METADATA) - - verifyBlocking(vssBackupClient) { - putObject(eq(BackupCategory.METADATA.name), dataCaptor.capture()) - } - val payload = json.decodeFromString(dataCaptor.firstValue.decodeToString()) - assertEquals(refundAddress, payload.cache.blocktankRefundAddress) - } - - @Test - fun `metadata restore preserves the Blocktank refund address pointer`() = test { - val refundAddress = BlocktankRefundAddress(address = "bcrt1qrefund", index = 7) - stubMetadataRestore(envelope = metadataEnvelope(cache = AppCacheData(blocktankRefundAddress = refundAddress))) - val cacheTransform = argumentCaptor<(AppCacheData) -> AppCacheData>() - - sut.performFullRestoreFromLatestBackup() - - verifyBlocking(cacheStore) { update(cacheTransform.capture()) } - assertEquals(refundAddress, cacheTransform.firstValue(AppCacheData()).blocktankRefundAddress) - } - @Test fun `metadata backup carries the hardware wallet names`() = test { stubMetadataBackupReads() From a38626c112e623529d6128cc2ea596db82fdb9fb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 19:11:44 +0100 Subject: [PATCH 4/6] fix: translate the transfer cancel note --- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + 15 files changed, 15 insertions(+) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 9b9bf5a16a..c5a866c6ca 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -968,6 +968,7 @@ انتهاء صلاحية الفاتورة الحد الأقصى القابل للإنفاق أقل قليلاً بسبب الرصيد الاحتياطي المطلوب. الرصيد الاحتياطي + لم تعد التحويلات إلى رصيد الإنفاق متاحة. يتحقق Bitkit أولًا من الشبكة، ثم يعيد عملات هذا التحويل إلى محفظتك حتى لا يتأكد التحويل الأصلي أبدًا. يكلّف ذلك رسوم شبكة. QuickPay جارٍ دفع\n<accent>الفاتورة...</accent> تأكيد 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 a0867040b6..6de031f75f 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -999,6 +999,7 @@ Expiración de la factura La cantidad máxima que se puede gastar es un poco más baja debido al saldo de reserva exigido. Saldo de reserva + Las transferencias al saldo de gasto ya no están disponibles. Bitkit primero revisa la red y luego devuelve las monedas de esta transferencia a tu billetera para que la transferencia original nunca se confirme. Esto tiene una comisión de red. Este pago está tardando un poco más de lo esperado. Puede seguir utilizando Bitkit. Pago pendiente QuickPay diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 99456e9a70..4ed3e2fcec 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -993,6 +993,7 @@ Caducitat de la factura L\'import màxim gastable és una mica inferior a causa d\'un saldo de reserva requerit. Saldo de reserva + Les transferències al saldo de despesa ja no estan disponibles. Bitkit primer comprova la xarxa i després retorna les monedes d\'aquesta transferència a la teva cartera perquè la transferència original no es pugui confirmar mai. Això té una comissió de xarxa. Pagament pendent QuickPay Pagant\n<accent>factura...</accent> diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index da8e1ed581..d6a1fd389e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -997,6 +997,7 @@ Expirace faktury Maximální částka, kterou lze utratit, je o něco nižší kvůli požadovanému zůstatku rezervy. Zůstatek rezervy + Převody na útratový zůstatek už nejsou dostupné. Bitkit nejprve zkontroluje síť a pak vrátí mince tohoto převodu do vaší peněženky, aby se původní převod nikdy nepotvrdil. Stojí to síťový poplatek. Tato platba trvá o něco déle, než se očekávalo. Bitkit můžete používat i nadále. Platba čeká na vyřízení QuickPay diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8718cde10c..81ab795b4f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -832,6 +832,7 @@ Senden Empfangen Bitcoin senden + Überweisungen auf dein Ausgabenguthaben sind nicht mehr verfügbar. Bitkit prüft zuerst das Netzwerk und schickt die Coins dieser Überweisung dann zurück in deine Wallet, damit die ursprüngliche Überweisung nie bestätigt wird. Dafür fällt eine Netzwerkgebühr an. An Kontakt Rechnung einfügen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index cc21331e47..73ccc37dc1 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -973,6 +973,7 @@ Λήξη τιμολογίου Το μέγιστο δαπανήσιμο ποσό είναι λίγο χαμηλότερο λόγω απαιτούμενου αποθεματικού υπολοίπου. Αποθεματικό υπόλοιπο + Οι μεταφορές στο υπόλοιπο δαπανών δεν είναι πλέον διαθέσιμες. Το Bitkit ελέγχει πρώτα το δίκτυο και μετά επιστρέφει τα νομίσματα αυτής της μεταφοράς στο πορτοφόλι σας, ώστε η αρχική μεταφορά να μην επιβεβαιωθεί ποτέ. Αυτό έχει χρέωση δικτύου. QuickPay Πληρωμή\n<accent>τιμολογίου...</accent> Επιβεβαίωση diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index d3702c1957..fd4d42acfc 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -994,6 +994,7 @@ Expiración de la factura La cantidad máxima gastable es un poco menor debido a un saldo de reserva requerido. Saldo de reserva + Las transferencias al saldo de gasto ya no están disponibles. Bitkit primero comprueba la red y después devuelve las monedas de esta transferencia a tu billetera para que la transferencia original nunca se confirme. Esto tiene una comisión de red. QuickPay Pagando\n<accent>factura...</accent> Confirmar diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a0b6cb8d5a..09d3b0329b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -830,6 +830,7 @@ Recibir Transferencia Entrante: Enviar Bitcoin + Las transferencias al saldo de gasto ya no están disponibles. Bitkit primero comprueba la red y después devuelve las monedas de esta transferencia a tu billetera para que la transferencia original nunca se confirme. Esto tiene una comisión de red. Para Contactar Pegar factura diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d18a608fa8..dd756b63df 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -833,6 +833,7 @@ Envoyer Recevoir Envoyer des bitcoins + Les transferts vers le solde de dépenses ne sont plus disponibles. Bitkit vérifie d\'abord le réseau, puis renvoie les pièces de ce transfert vers votre portefeuille afin que le transfert d\'origine ne soit jamais confirmé. Cela coûte des frais de réseau. Pour Contact Coller la facture diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0d27e4b38e..3ae6a58772 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -995,6 +995,7 @@ Scadenza invoice L\'importo massimo spendibile e\' leggermente inferiore a causa di un saldo di riserva richiesto. Saldo di Riserva + I trasferimenti al saldo di spesa non sono più disponibili. Bitkit controlla prima la rete, poi rimanda le monete di questo trasferimento al tuo wallet, così il trasferimento originale non potrà mai essere confermato. Questo costa una commissione di rete. Questo pagamento sta richiedendo un po\' più tempo del previsto. Puoi continuare a utilizzare Bitkit. Pagamento in sospeso QuickPay diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 46f75e9412..04e4202803 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -990,6 +990,7 @@ Factuutvervaldatum Het maximaal te besteden bedrag is iets lager vanwege een vereist reservesaldo. Reservesaldo + Overboekingen naar je bestedingssaldo zijn niet meer beschikbaar. Bitkit controleert eerst het netwerk en stuurt de munten van deze overboeking dan terug naar je wallet, zodat de oorspronkelijke overboeking nooit bevestigd wordt. Dit kost netwerkkosten. Deze betaling duurt iets langer dan verwacht. U kunt Bitkit blijven gebruiken. Betaling Onderweg QuickPay diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index fbac023b54..599e844364 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -835,6 +835,7 @@ Wyślij Otrzymaj Wyślij Bitcoin + Przelewy na saldo wydatków nie są już dostępne. Bitkit najpierw sprawdza sieć, a potem odsyła monety z tego przelewu do Twojego portfela, aby pierwotny przelew nigdy nie został potwierdzony. Wiąże się to z opłatą sieciową. Do Kontakt Wklej fakturę diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b90155fcc1..95e064b729 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -999,6 +999,7 @@ Vencimento do invoice O valor máximo utilizável é um pouco menor devido a um saldo de reserva obrigatório. Saldo de reserva + As transferências para o saldo de gastos não estão mais disponíveis. O Bitkit primeiro verifica a rede e depois devolve as moedas desta transferência para sua carteira, para que a transferência original nunca seja confirmada. Isso tem uma taxa de rede. Esse pagamento está demorando mais do que o esperado. Enquanto isso, você pode continuar usando a Bitkit. Pagamento Pendente QuickPay diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index ce0bf7577a..7c6c20fe77 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -700,6 +700,7 @@ Enviar Receber Enviar Bitcoin + As transferências para o saldo de gastos já não estão disponíveis. A Bitkit verifica primeiro a rede e depois devolve as moedas desta transferência à sua carteira, para que a transferência original nunca seja confirmada. Isto tem uma taxa de rede. Para Contato Colar Invoice diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bd8a1adc3f..cc393f54a1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1014,6 +1014,7 @@ Срок действия инвойса Максимальная сумма для траты немного меньше из-за необходимого резервного баланса. Резервный баланс + Переводы на баланс расходов больше недоступны. Bitkit сначала проверяет сеть, а затем возвращает монеты этого перевода в ваш кошелёк, чтобы исходный перевод никогда не подтвердился. За это взимается сетевая комиссия. Этот платеж занимает больше времени, чем ожидалось. Вы можете продолжать использовать Bitkit. Платеж в ожидании QuickPay From eedf7970ea5aea4e27910749fa3628d7e0fe4f0b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 19:15:28 +0100 Subject: [PATCH 5/6] fix: treat a scanned gift code as unsupported --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 ------ .../viewmodels/AppViewModelSendFlowTest.kt | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c3367d18d7..ddb119a4f4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4603,7 +4603,6 @@ class AppViewModel @Inject constructor( is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data, fromMainScanner) } is Scanner.LnurlChannel -> handleNonPaymentScan { onScanLnurlChannel(scan.data) } is Scanner.NodeId -> handleNonPaymentScan { onScanNodeId(scan) } - is Scanner.Gift -> handleNonPaymentScan { onScanGift() } else -> { val hasIncomingPaymentRequest = clearIncomingPaymentRequestTarget() val logMessage = if (hasIncomingPaymentRequest) { @@ -5454,11 +5453,6 @@ class AppViewModel @Inject constructor( mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) } - private fun onScanGift() { - hideSheet() // hide scan sheet if opened - showSheet(Sheet.TransferUnavailable) - } - private suspend fun handleQuickPayIfApplicable( amountSats: ULong, fromMainScanner: Boolean, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index bd2af98955..e7faafe08d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -3586,6 +3586,24 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) } + @Test + fun `scanned gift code shows the unsupported code error and opens no sheet`() = test { + val giftCode = "bitkit://gift-abc123-3000" + whenever(context.getString(R.string.other__qr_error_header)).thenReturn("Unsupported QR") + whenever(context.getString(R.string.other__qr_error_text)).thenReturn("Bitkit can't read this QR.") + whenever(coreService.decode(giftCode)).thenReturn(Scanner.Gift(code = "abc123", amount = 3_000uL)) + clearInvocations(toastManager) + + sut.onScanResult(giftCode) + advanceUntilIdle() + + val toastCaptor = argumentCaptor() + verify(toastManager, atLeastOnce()).enqueue(toastCaptor.capture()) + assertEquals("Unsupported QR", toastCaptor.lastValue.title) + assertEquals("Bitkit can't read this QR.", toastCaptor.lastValue.description) + assertNull(sut.currentSheet.value) + } + @Test fun `new scan does not inherit an active payment request context`() = test { sut.setIsAuthenticated(true) From 94208b389c68ba1cb5c18d80a446cb640892f44e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 19:43:41 +0100 Subject: [PATCH 6/6] fix: drop paid-order recovery now that Blocktank opens no channels --- .../main/java/to/bitkit/data/CacheStore.kt | 18 - .../java/to/bitkit/repositories/BackupRepo.kt | 7 +- .../to/bitkit/repositories/BlocktankRepo.kt | 59 +-- .../java/to/bitkit/repositories/HealthRepo.kt | 21 - .../to/bitkit/repositories/LightningRepo.kt | 19 +- .../to/bitkit/repositories/TransferRepo.kt | 143 +----- .../to/bitkit/services/MigrationService.kt | 147 +----- .../bitkit/ui/settings/ChannelOrdersScreen.kt | 27 -- .../settings/lightning/ChannelDetailScreen.kt | 2 +- .../lightning/ChannelDetailViewModel.kt | 32 +- .../lightning/LightningConnectionsScreen.kt | 37 +- .../LightningConnectionsViewModel.kt | 104 +--- .../usecases/DeriveBalanceStateUseCase.kt | 52 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 - .../bitkit/viewmodels/BlocktankViewModel.kt | 4 - app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-b+es+419/strings.xml | 1 - app/src/main/res/values-ca/strings.xml | 1 - app/src/main/res/values-cs/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-el/strings.xml | 1 - app/src/main/res/values-es-rES/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - .../java/to/bitkit/data/AppCacheDataTest.kt | 2 - .../to/bitkit/repositories/BackupRepoTest.kt | 22 +- .../bitkit/repositories/BlocktankRepoTest.kt | 60 --- .../to/bitkit/repositories/HealthRepoTest.kt | 25 - .../bitkit/repositories/LightningRepoTest.kt | 6 +- .../bitkit/repositories/QuickPayRepoTest.kt | 2 - .../bitkit/repositories/TransferRepoTest.kt | 444 +----------------- .../usecases/DeriveBalanceStateUseCaseTest.kt | 67 --- docs/balance.md | 14 +- docs/transfer.md | 67 +-- 41 files changed, 90 insertions(+), 1309 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index ce58f67e03..1c55b18947 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -41,19 +41,6 @@ class CacheStore internal constructor( store.updateData(transform) } - suspend fun addPaidOrder(orderId: String, txId: String) { - store.updateData { - val newEntry = mapOf(orderId to txId) - val updatedOrders = newEntry + it.paidOrders - val limitedOrders = when { - updatedOrders.size > MAX_PAID_ORDERS -> updatedOrders.toList().take(MAX_PAID_ORDERS).toMap() - else -> updatedOrders - } - Logger.debug("Cached paid order '$orderId'") - it.copy(paidOrders = limitedOrders) - } - } - suspend fun setOnchainAddress(address: String) { store.updateData { it.copy(onchainAddress = address) } } @@ -161,16 +148,11 @@ class CacheStore internal constructor( store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") } - - companion object { - private const val MAX_PAID_ORDERS = 50 - } } @Serializable data class AppCacheData( val cachedRates: List = listOf(), - val paidOrders: Map = mapOf(), val onchainAddress: String = "", val bolt11: String = "", val bolt11PaymentHash: String = "", diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index cf99346ba0..24fbc9926a 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -126,7 +126,6 @@ class BackupRepo @Inject constructor( private val paykitPresentationStore: PaykitPaymentRequestPresentationStore, private val keychain: Keychain, private val onchainSendAttemptStore: OnchainSendAttemptStore, - private val transferRepo: Provider, private val lightningRepo: Provider, private val vssStoreIdProvider: VssStoreIdProvider, private val preActivityMetadataRepo: PreActivityMetadataRepo, @@ -968,11 +967,7 @@ class BackupRepo @Inject constructor( it.attemptId == restored.attemptId && it.walletIndex == restored.walletIndex } }?.takeIf { it.hasPositiveEvidence }?.let { attempt -> - if (attempt.isTransfer) { - transferRepo.get().resumeAcceptedFunding(attempt).onFailure { - Logger.warn("Restored accepted funding remains pending local follow-up", it, context = TAG) - } - } else if (attempt.requestId != null && !attempt.localFollowupComplete) { + if (attempt.requestId != null && !attempt.localFollowupComplete) { runSuspendCatching { paykitPaymentProofRepo.get().reconcile() }.onFailure { Logger.warn("Restored accepted Shop payment remains pending local follow-up", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index fbf9e182ac..62a6b68347 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -1,7 +1,6 @@ package to.bitkit.repositories import androidx.compose.runtime.Stable -import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.CJitStateEnum import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder @@ -17,8 +16,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn @@ -31,7 +28,6 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.async.appScope -import to.bitkit.data.CacheStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching @@ -47,7 +43,6 @@ import kotlin.time.Duration.Companion.seconds class BlocktankRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, - private val cacheStore: CacheStore, @Named("enablePolling") private val enablePolling: Boolean, ) { private val repoScope = appScope(bgDispatcher, TAG) @@ -60,7 +55,6 @@ class BlocktankRepo @Inject constructor( init { startPolling() - observePaidOrders() repoScope.launch { refreshInfo() @@ -80,22 +74,6 @@ class BlocktankRepo @Inject constructor( .launchIn(repoScope) } - private fun observePaidOrders() { - repoScope.launch { - cacheStore.data - .map { it.paidOrders } - .distinctUntilChanged() - .map { it.keys } - .collect { paidOrderIds -> - _blocktankState.update { state -> - state.copy( - paidOrders = state.orders.filter { order -> order.id in paidOrderIds }.toImmutableList(), - ) - } - } - } - } - suspend fun getCjitEntry(channel: ChannelDetails): IcJitEntry? = withContext(bgDispatcher) { val fundingTxo = channel.fundingTxo ?: return@withContext null @@ -164,8 +142,6 @@ class BlocktankRepo @Inject constructor( runCatching { Logger.verbose("Refreshing blocktank orders…", context = TAG) - val paidOrderIds = cacheStore.data.first().paidOrders.keys - // Sync instantly from cache val cachedOrders = coreService.blocktank.orders(refresh = false) val cachedCjitEntries = coreService.blocktank.cjitEntries(refresh = false) @@ -173,7 +149,6 @@ class BlocktankRepo @Inject constructor( state.copy( orders = cachedOrders.toImmutableList(), cjitEntries = cachedCjitEntries.toImmutableList(), - paidOrders = cachedOrders.filter { order -> order.id in paidOrderIds }.toImmutableList(), ) } @@ -184,42 +159,18 @@ class BlocktankRepo @Inject constructor( state.copy( orders = orders.toImmutableList(), cjitEntries = cjitEntries.toImmutableList(), - paidOrders = orders.filter { order -> order.id in paidOrderIds }.toImmutableList(), ) } Logger.debug( - "Orders refreshed: ${orders.size} orders, " + - "${cjitEntries.size} cjit entries, " + - "${_blocktankState.value.paidOrders.size} paid orders", + "Orders refreshed: ${orders.size} orders, ${cjitEntries.size} cjit entries", context = TAG ) - openChannelWithPaidOrders() }.also { isRefreshing = false } } - suspend fun openChannel(orderId: String): Result = withContext(bgDispatcher) { - runCatching { - Logger.debug("Opening channel for order: '$orderId'", context = TAG) - val order = coreService.blocktank.open(orderId) - - // Update the order in state - val updatedOrders = _blocktankState.value.orders.toMutableList() - val index = updatedOrders.indexOfFirst { it.id == order.id } - if (index != -1) { - updatedOrders[index] = order - } - - _blocktankState.update { state -> state.copy(orders = updatedOrders.toImmutableList()) } - - return@runCatching order - }.onFailure { - Logger.error("Failed to open channel for order: $orderId", it, context = TAG) - } - } - suspend fun getOrder( orderId: String, refresh: Boolean = false, @@ -252,12 +203,6 @@ class BlocktankRepo @Inject constructor( } } - private suspend fun openChannelWithPaidOrders() = withContext(bgDispatcher) { - _blocktankState.value.paidOrders.filter { it.state2 == BtOrderState2.PAID }.forEach { order -> - openChannel(order.id) - } - } - suspend fun resetState() = withContext(bgDispatcher) { _blocktankState.update { BlocktankState() } Logger.debug("Blocktank state reset", context = TAG) @@ -272,7 +217,6 @@ class BlocktankRepo @Inject constructor( } // We don't refresh orders here because we rely on the polling mechanism. - // We also don't restore `paidOrders` the refresh interval uses restored paidOrderIds to rebuild the list. _blocktankState.update { it.copy( @@ -297,7 +241,6 @@ class BlocktankRepo @Inject constructor( @Stable data class BlocktankState( val orders: ImmutableList = persistentListOf(), - val paidOrders: ImmutableList = persistentListOf(), val cjitEntries: ImmutableList = persistentListOf(), val info: IBtInfo? = null, ) diff --git a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt index 96005c4c52..5c79d4a075 100644 --- a/app/src/main/java/to/bitkit/repositories/HealthRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HealthRepo.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -28,7 +27,6 @@ class HealthRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val connectivityRepo: ConnectivityRepo, private val lightningRepo: LightningRepo, - private val blocktankRepo: BlocktankRepo, private val cacheStore: CacheStore, private val clock: Clock, ) { @@ -43,7 +41,6 @@ class HealthRepo @Inject constructor( init { collectState() - observePaidOrdersState() observeBackupStatus() } @@ -120,24 +117,6 @@ class HealthRepo @Inject constructor( ConnectivityState.DISCONNECTED -> HealthState.ERROR } - private fun observePaidOrdersState() { - repoScope.launch { - blocktankRepo.blocktankState.map { it.paidOrders }.distinctUntilChanged().collect { paidOrders -> - if (paidOrders.isNotEmpty()) { - updateState { currentState -> - // only override lightning connection to PENDING if it's currently ERROR - val channelsStatus = when (currentState.channels) { - HealthState.ERROR -> HealthState.PENDING - else -> currentState.channels - } - - currentState.copy(channels = channelsStatus) - } - } - } - } - } - private fun observeBackupStatus() { repoScope.launch { cacheStore.backupStatuses.collect { backupStatuses -> diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 5c5b9040dc..32e28e5b7d 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -550,8 +550,7 @@ class LightningRepo @Inject constructor( ) val attempt = observed ?: onchainSendAttemptStore.current() if (attempt != null && attempt.txid.equals(observedTxid, ignoreCase = true) && - attempt.hasPositiveEvidence && !attempt.localFollowupComplete && - !attempt.isTransfer && attempt.requestId == null + attempt.hasPositiveEvidence && !attempt.localFollowupComplete && attempt.requestId == null ) { finishOnchainSendLocally(attempt) onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) @@ -1821,9 +1820,7 @@ class LightningRepo @Inject constructor( delay(1.seconds) if (!_lightningState.value.nodeLifecycleState.isRunning()) return@launch val attempt = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() ?: return@launch - if (!attempt.hasPositiveEvidence || attempt.localFollowupComplete || - attempt.isTransfer || attempt.requestId != null - ) { + if (!attempt.hasPositiveEvidence || attempt.localFollowupComplete || attempt.requestId != null) { return@launch } completeAcceptedOrdinaryFollowup(requireNotNull(attempt.txid)) @@ -1834,7 +1831,7 @@ class LightningRepo @Inject constructor( suspend fun completeAcceptedOrdinaryFollowup(txid: String) { val attempt = onchainSendAttemptStore.current() - if (attempt != null && !attempt.isTransfer && attempt.requestId == null && + if (attempt != null && attempt.requestId == null && attempt.txid.equals(txid, ignoreCase = true) && attempt.hasPositiveEvidence ) { runSuspendCatching { @@ -1844,16 +1841,6 @@ class LightningRepo @Inject constructor( } } - suspend fun completeAcceptedTransferFollowup(orderId: String, txid: String) { - val attempt = onchainSendAttemptStore.current() - if (attempt?.orderId == orderId && attempt.txid.equals(txid, ignoreCase = true) && - attempt.hasPositiveEvidence - ) { - finishOnchainSendLocally(attempt) - onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) - } - } - // A change signal only: consumers must reread and validate the original attempt. val onchainSendAttemptUpdates get() = onchainSendAttemptStore.backupStateVersion diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index f62bd67458..041def1907 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -9,22 +9,13 @@ import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails -import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PendingSweepBalance -import to.bitkit.async.appScope -import to.bitkit.data.CacheStore import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher @@ -35,7 +26,6 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.TransferType import to.bitkit.models.WalletScope import to.bitkit.services.CoreService -import to.bitkit.utils.AppError import to.bitkit.utils.BlockTimeHelpers import to.bitkit.utils.Logger import java.util.UUID @@ -54,95 +44,9 @@ class TransferRepo @Inject constructor( private val coreService: CoreService, private val transferDao: TransferDao, private val clock: Clock, - private val cacheStore: CacheStore, - private val connectivityRepo: ConnectivityRepo, ) { private val lastOrdersFetchMs = AtomicLong(0L) - private val fundingMutex = Mutex() - private val repoScope = appScope(bgDispatcher, TAG) - - init { - repoScope.launch { - lightningRepo.lightningState.map { it.nodeLifecycleState.isRunning() }.distinctUntilChanged() - .collectLatest { running -> if (running) resumeAcceptedFunding() } - } - repoScope.launch { - lightningRepo.nodeEvents.collect { event -> - if (event is Event.OnchainTransactionReceived || event is Event.OnchainTransactionConfirmed) { - resumeAcceptedFunding() - } - } - } - repoScope.launch { - connectivityRepo.isOnline.map { it == ConnectivityState.CONNECTED }.distinctUntilChanged().drop(1) - .collectLatest { connected -> - if (connected && lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { - resumeAcceptedFunding() - } - } - } - } - - /** Uses only the bounded attempt's positive evidence and original order/balance context. */ - suspend fun resumeAcceptedFunding(expectedAttempt: OnchainSendAttempt? = null): Result = runSuspendCatching { - val attempt = lightningRepo.currentOnchainSendAttempt() ?: return@runSuspendCatching - if (expectedAttempt != null) { - check( - attempt.attemptId == expectedAttempt.attemptId && - attempt.walletIndex == expectedAttempt.walletIndex && attempt.walletId == expectedAttempt.walletId - ) { "Restored funding operation is no longer current" } - } - if (!attempt.isTransfer || !attempt.hasPositiveEvidence || attempt.localFollowupComplete) { - return@runSuspendCatching - } - val txid = requireNotNull(attempt.txid) - val orderId = requireNotNull(attempt.orderId) - val order = blocktankRepo.fetchOrders(listOf(orderId)).getOrThrow().firstOrNull { it.id == orderId } - ?: throw AppError("Original funding order is unavailable") - check(order.payment?.onchain?.address == attempt.address) { "Original funding order address changed" } - val context = requireNotNull(attempt.transferContext) { "Original funding context is unavailable" } - check( - order.clientBalanceSat == context.originalOrderClientBalanceSats && - order.feeSat == context.originalOrderFeeSats - ) { "Original funding order terms changed or are unavailable" } - check(lightningRepo.currentOnchainSendAttempt() == attempt) { "Original funding operation changed" } - persistAcceptedFunding(order, txid, context).getOrThrow() - lightningRepo.completeAcceptedTransferFollowup(orderId, txid) - }.onFailure { Logger.warn("Failed to resume accepted funding", it, context = TAG) } - - /** Serialized across startup/events and the confirmation screen; never broadcasts. */ - suspend fun persistAcceptedFunding( - order: IBtOrder, - txid: String, - originalContext: OnchainTransferContext?, - ): Result = withContext(bgDispatcher) { - runSuspendCatching { - fundingMutex.withLock { - val context = requireNotNull( - originalContext - ) { "Accepted transfer is missing its original balance context" } - val existing = transferDao.getByFundingTxId(txid) - check(existing == null || existing.lspOrderId == order.id) { - "Funding transaction is already assigned to another order" - } - val paid = cacheStore.data.first().paidOrders[order.id] - check(paid == null || paid == txid) { "Order is already assigned to another funding transaction" } - if (existing == null) { - createTransfer( - type = TransferType.TO_SPENDING, - amountSats = order.clientBalanceSat.toLong(), - fundingTxId = txid, - lspOrderId = order.id, - txTotalSats = context.txTotalSats.toLong(), - preTransferOnchainSats = context.preTransferOnchainSats.toLong(), - ).getOrThrow() - } - cacheStore.addPaidOrder(orderId = order.id, txId = txid) - } - } - } - val activeTransfers: Flow> = transferDao.getActiveTransfers() val forceCloseRemainingDuration: Flow = combine( @@ -206,32 +110,6 @@ class TransferRepo @Inject constructor( } } - suspend fun createPendingToSpendingActivity( - order: IBtOrder, - txId: String, - fee: ULong, - feeRate: ULong, - walletId: String = WalletScope.default, - ): Result = withContext(bgDispatcher) { - runSuspendCatching { - val address = requireNotNull(order.payment?.onchain?.address?.takeIf { it.isNotEmpty() }) { - "Order '${order.id}' has no on-chain payment address" - } - coreService.activity.createSentOnchainActivityFromSendResult( - txid = txId, - address = address, - amount = order.feeSat, - fee = fee, - feeRate = feeRate, - isTransfer = true, - channelId = order.channel?.shortChannelId, - walletId = walletId, - ) - }.onFailure { - Logger.error("Failed to create pending transfer activity for '$txId'", it, context = TAG) - } - } - suspend fun findLspOrderIdByFundingTxId(fundingTxId: String): Result = withContext(bgDispatcher) { runSuspendCatching { transferDao.getByFundingTxId(fundingTxId)?.lspOrderId @@ -311,7 +189,7 @@ class TransferRepo @Inject constructor( channelId != null -> settleIfChannelClosed(transfer, channelId, closedChannelIds) - transfer.lspOrderId != null -> settleIfOrderEnded(transfer, transfer.lspOrderId) + transfer.lspOrderId != null -> settleIfOrderWithoutChannel(transfer, transfer.lspOrderId) } } } @@ -371,22 +249,13 @@ class TransferRepo @Inject constructor( ) } - private suspend fun settleIfOrderEnded(transfer: TransferEntity, orderId: String) { + /** Blocktank no longer opens channels, so an order without a channel by now never gets one. */ + private suspend fun settleIfOrderWithoutChannel(transfer: TransferEntity, orderId: String) { val order = blocktankRepo.getOrder(orderId, refresh = false).getOrNull() ?: return + if (order.channel != null && !order.hasClosedChannel()) return - if (order.state2 == BtOrderState2.EXPIRED) { - markSettled(transfer.id) - Logger.info("Settled transfer '${transfer.id}' for expired order '$orderId'", context = TAG) - return - } - - if (order.hasClosedChannel()) { - markSettled(transfer.id) - Logger.info( - "Settled transfer '${transfer.id}' for order '$orderId' with channel state '${order.channel?.state}'", - context = TAG, - ) - } + markSettled(transfer.id) + Logger.info("Settled transfer '${transfer.id}' for order '$orderId' with no open channel", context = TAG) } private suspend fun loadClosedChannelIds(): Set = runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 5c495a1fba..9e47625fde 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -43,7 +43,6 @@ import to.bitkit.data.WidgetsStore import to.bitkit.data.dao.TransferDao import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.TradingPair -import to.bitkit.data.entities.TransferEntity import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.json @@ -205,9 +204,6 @@ class MigrationService internal constructor( @Volatile private var pendingRemoteMetadata: RNMetadata? = null - @Volatile - private var pendingRemotePaidOrders: Map? = null - @Volatile private var pendingBlocktankOrderIds: List? = null @@ -241,10 +237,10 @@ class MigrationService internal constructor( prefs[stringPreferencesKey(RN_PENDING_PAID_ORDERS_KEY)]?.let { data -> runCatching { - pendingRemotePaidOrders = json.decodeFromString>(data) - Logger.debug("Loaded ${pendingRemotePaidOrders?.size} pending paid orders", context = TAG) + val paidOrderIds = json.decodeFromString>(data).keys + pendingBlocktankOrderIds = (pendingBlocktankOrderIds.orEmpty() + paidOrderIds).distinct() }.onFailure { - Logger.warn("Failed to load pending paid orders", it, context = TAG) + Logger.warn("Failed to load pending paid order IDs", it, context = TAG) } } @@ -285,14 +281,6 @@ class MigrationService internal constructor( Logger.info("Persisted ${orderIds.size} Blocktank order IDs for retry", context = TAG) } - private suspend fun persistPaidOrders(paidOrders: Map) { - val key = stringPreferencesKey(RN_PENDING_PAID_ORDERS_KEY) - rnMigrationStore.edit { - it[key] = json.encodeToString(paidOrders) - } - pendingRemotePaidOrders = paidOrders - } - private suspend fun persistMetadata(metadata: RNMetadata) { val key = stringPreferencesKey(RN_PENDING_METADATA_KEY) rnMigrationStore.edit { @@ -326,7 +314,6 @@ class MigrationService internal constructor( it.remove(stringPreferencesKey(RN_PENDING_PAID_ORDERS_KEY)) } pendingBlocktankOrderIds = null - pendingRemotePaidOrders = null Logger.debug("Cleared persisted Blocktank data", context = TAG) } @@ -364,7 +351,6 @@ class MigrationService internal constructor( it.remove(stringPreferencesKey(RN_PENDING_BOOSTS_KEY)) } pendingBlocktankOrderIds = null - pendingRemotePaidOrders = null pendingRemoteMetadata = null pendingRemoteTransfers = null pendingRemoteBoosts = null @@ -405,7 +391,7 @@ class MigrationService internal constructor( val canCleanupAfterMigration: Boolean get() { - if (pendingBlocktankOrderIds != null || pendingRemotePaidOrders != null) { + if (pendingBlocktankOrderIds != null) { Logger.debug("Cannot cleanup: pending Blocktank data exists", context = TAG) return false } @@ -913,7 +899,7 @@ class MigrationService internal constructor( } } - private fun extractRNBlocktank(mmkvData: Map): Pair, Map>? { + private fun extractRNBlocktank(mmkvData: Map): List? { val rootJson = mmkvData[MMKV_ROOT] ?: return null return runCatching { @@ -925,7 +911,6 @@ class MigrationService internal constructor( val blocktankData = json.parseToJsonElement(blocktankJsonString).jsonObject val orderIds = mutableListOf() - val paidOrdersMap = mutableMapOf() blocktankData["orders"]?.jsonArray?.forEach { orderElement -> orderElement.jsonObject["id"]?.jsonPrimitive?.content?.let { id -> @@ -933,23 +918,16 @@ class MigrationService internal constructor( } } - blocktankData["paidOrders"]?.jsonObject?.forEach { (orderId, txIdElement) -> - val txId = txIdElement.jsonPrimitive.content - paidOrdersMap[orderId] = txId + blocktankData["paidOrders"]?.jsonObject?.keys?.forEach { orderId -> if (orderId !in orderIds) { orderIds.add(orderId) } } - if (orderIds.isEmpty() && paidOrdersMap.isEmpty()) { - return@runCatching null - } + if (orderIds.isEmpty()) return@runCatching null - Logger.info( - "Extracted ${orderIds.size} order IDs and ${paidOrdersMap.size} paid orders from local blocktank", - context = TAG, - ) - Pair(orderIds, paidOrdersMap) + Logger.info("Extracted ${orderIds.size} order IDs from local blocktank", context = TAG) + orderIds }.onFailure { Logger.error("Failed to decode RN blocktank: $it", it, context = TAG) }.getOrNull() @@ -1341,32 +1319,20 @@ class MigrationService internal constructor( applyRNTodos(todos) } - extractRNBlocktank(mmkvData)?.let { (orderIds, paidOrders) -> - applyRNBlocktank(orderIds, paidOrders) + extractRNBlocktank(mmkvData)?.let { orderIds -> + applyRNBlocktank(orderIds) } } - private suspend fun applyRNBlocktank(orderIds: List, paidOrders: Map) { + private suspend fun applyRNBlocktank(orderIds: List) { if (orderIds.isEmpty()) return - paidOrders.forEach { (orderId, txId) -> - cacheStore.addPaidOrder(orderId, txId) - } - runCatching { val fetchedOrders = fetchOrdersChunked(orderIds) - if (fetchedOrders.isNotEmpty()) { - coreService.blocktank.upsertOrderList(fetchedOrders) - if (paidOrders.isNotEmpty()) { - createTransfersForPaidOrders(paidOrders, fetchedOrders) - } - } + if (fetchedOrders.isNotEmpty()) coreService.blocktank.upsertOrderList(fetchedOrders) }.onFailure { e -> Logger.warn("Failed to fetch and upsert local Blocktank orders", e, context = TAG) persistBlocktankOrderIds(orderIds) - if (paidOrders.isNotEmpty()) { - persistPaidOrders(paidOrders) - } Logger.info("Stored ${orderIds.size} Blocktank order IDs for retry", context = TAG) } } @@ -1694,64 +1660,15 @@ class MigrationService internal constructor( runCatching { val backup = decodeBackupData(data) - backup.paidOrders?.let { paidOrders -> - paidOrders.forEach { (orderId, txId) -> - cacheStore.addPaidOrder(orderId, txId) - } - if (paidOrders.isNotEmpty()) { - pendingRemotePaidOrders = paidOrders - } + val paidOrderIds = backup.paidOrders?.keys.orEmpty() + if (paidOrderIds.isNotEmpty()) { + persistBlocktankOrderIds((pendingBlocktankOrderIds.orEmpty() + paidOrderIds).distinct()) } }.onFailure { e -> Logger.warn("Failed to decode RN remote blocktank backup: $e", context = TAG) } } - private suspend fun createTransfersForPaidOrders( - paidOrdersMap: Map, - orders: List, - ) { - val now = System.currentTimeMillis() / 1000 - val transfers = paidOrdersMap.mapNotNull { (orderId, txId) -> - val order = orders.find { it.id == orderId } - when { - order == null -> { - Logger.warn("Paid order $orderId not found in fetched orders", context = TAG) - null - } - - order.state2 != BtOrderState2.PAID -> { - Logger.debug( - "Skipping order $orderId with state ${order.state2} for transfer creation", - context = TAG, - ) - null - } - - else -> TransferEntity( - id = txId, - type = TransferType.TO_SPENDING, - amountSats = order.clientBalanceSat.toLong(), - channelId = null, - fundingTxId = null, - lspOrderId = orderId, - isSettled = false, - createdAt = now, - settledAt = null, - ) - } - } - - if (transfers.isNotEmpty()) { - runCatching { - transferDao.upsert(transfers) - Logger.info("Created ${transfers.size} transfers for paid Blocktank orders", context = TAG) - }.onFailure { e -> - Logger.error("Failed to create transfers for paid orders: $e", context = TAG) - } - } - } - suspend fun reapplyMetadataAfterSync() { loadPersistedMigrationData() @@ -1806,7 +1723,6 @@ class MigrationService internal constructor( } } - var blocktankFetchFailed = false pendingBlocktankOrderIds?.let { orderIds -> if (orderIds.isNotEmpty()) { Logger.info("Retrying ${orderIds.size} pending Blocktank orders", context = TAG) @@ -1815,49 +1731,18 @@ class MigrationService internal constructor( if (fetchedOrders.isNotEmpty()) { coreService.blocktank.upsertOrderList(fetchedOrders) Logger.info("Upserted ${fetchedOrders.size} Blocktank orders after retry", context = TAG) - - pendingRemotePaidOrders?.let { paidOrders -> - if (paidOrders.isNotEmpty()) { - Logger.info("Creating transfers for ${paidOrders.size} paid orders", context = TAG) - createTransfersForPaidOrders(paidOrders, fetchedOrders) - } - } } pendingBlocktankOrderIds = null - pendingRemotePaidOrders = null clearPersistedBlocktankData() }.onFailure { e -> Logger.warn("Still unable to fetch Blocktank orders", e, context = TAG) - blocktankFetchFailed = true } } } - if (!blocktankFetchFailed) { - pendingRemotePaidOrders?.let { paidOrders -> - applyRemotePaidOrders(paidOrders) - pendingRemotePaidOrders = null - } - } recordPendingMigrationRetry() } - private suspend fun applyRemotePaidOrders(paidOrders: Map) { - if (paidOrders.isEmpty()) return - - val orderIds = paidOrders.keys.toList() - - runCatching { - val fetchedOrders = fetchOrdersChunked(orderIds) - if (fetchedOrders.isNotEmpty()) { - coreService.blocktank.upsertOrderList(fetchedOrders) - createTransfersForPaidOrders(paidOrders, fetchedOrders) - } - }.onFailure { e -> - Logger.warn("Failed to fetch and process remote paid orders: $e", context = TAG) - } - } - internal suspend fun applyRemoteTransfers(transfers: Map): Map { val remaining = transfers.toMutableMap() transfers.forEach { (txId, channelId) -> diff --git a/app/src/main/java/to/bitkit/ui/settings/ChannelOrdersScreen.kt b/app/src/main/java/to/bitkit/ui/settings/ChannelOrdersScreen.kt index f11f60b898..ad20ce7721 100644 --- a/app/src/main/java/to/bitkit/ui/settings/ChannelOrdersScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/ChannelOrdersScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -38,7 +37,6 @@ import com.synonym.bitkitcore.ILspNode import com.synonym.bitkitcore.IcJitEntry import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.launch import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.Routes import to.bitkit.ui.blocktankViewModel @@ -46,7 +44,6 @@ import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.Caption import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Footnote -import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.DetailRow import to.bitkit.ui.components.settings.InfoCard @@ -59,7 +56,6 @@ import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard -import to.bitkit.utils.Logger @Composable fun ChannelOrdersScreen( @@ -140,22 +136,9 @@ fun OrderDetailScreen( val blocktank = blocktankViewModel ?: return val orders by blocktank.orders.collectAsStateWithLifecycle() val order = orders.find { it.id == orderItem.id } ?: return - val coroutineScope = rememberCoroutineScope() - OrderDetailContent( order = order, onBack = onBackClick, - onClickOpen = { - coroutineScope.launch { - Logger.info("Opening channel for order ${order.id}") - try { - blocktank.openChannel(orderId = order.id) - Logger.info("Channel opened for order ${order.id}") - } catch (e: Throwable) { - Logger.error("Error opening channel for order ${order.id}", e) - } - } - }, ) } @@ -163,7 +146,6 @@ fun OrderDetailScreen( private fun OrderDetailContent( order: IBtOrder, onBack: () -> Unit = {}, - onClickOpen: () -> Unit = {}, ) { Scaffold( topBar = { AppTopBar(titleText = "Order Details", onBackClick = onBack) } @@ -223,15 +205,6 @@ private fun OrderDetailContent( DetailRow("Updated", order.updatedAt) } } - if (order.state2 == BtOrderState2.PAID) { - item { - PrimaryButton( - text = "Open Channel", - onClick = onClickOpen, - modifier = Modifier.padding(horizontal = 16.dp) - ) - } - } } } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 2e3a75bb1f..4ed56e3571 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -164,7 +164,7 @@ private fun Content( is ChannelLoadState.Success -> { ChannelDetailContent( channel = loadState.channel, - blocktankOrders = uiState.paidOrders, + blocktankOrders = uiState.orders, cjitEntries = uiState.cjitEntries, txTime = uiState.txTime, isRefreshing = uiState.isRefreshing, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt index 6e350d5ce9..08599a8fdc 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt @@ -73,7 +73,7 @@ class ChannelDetailViewModel @Inject constructor( _uiState.update { it.copy( channelLoadState = ChannelLoadState.Success(channelUi), - paidOrders = blocktankRepo.blocktankState.value.paidOrders, + orders = blocktankRepo.blocktankState.value.orders, cjitEntries = blocktankRepo.blocktankState.value.cjitEntries, isClosedChannel = isClosedChannel, nodeId = lightningRepo.getNodeId().orEmpty(), @@ -96,7 +96,7 @@ class ChannelDetailViewModel @Inject constructor( val channels = lightningRepo.lightningState.value.channels val blocktankState = blocktankRepo.blocktankState.value - return resolveActiveChannel(channelId, channels, blocktankState.paidOrders) + return resolveActiveChannel(channelId, channels) ?: closedChannels.find { it.details.channelId == channelId } ?: blocktankState.orders.find { it.id == channelId }?.let { order -> createChannelDetails().copy( @@ -106,34 +106,22 @@ class ChannelDetailViewModel @Inject constructor( channelValueSats = order.clientBalanceSat.safe() + order.lspBalanceSat.safe(), outboundCapacityMsat = order.clientBalanceSat * 1000u, inboundCapacityMsat = order.lspBalanceSat * 1000u, - ).mapToUiModel(channels, blocktankState.paidOrders, connectionText) + ).mapToUiModel(channels, connectionText) } } - private fun resolveActiveChannel( - channelId: String, - channels: List, - paidOrders: List, - ): ChannelUi? = - channels.find { it.channelId == channelId } - ?.mapToUiModel(channels, paidOrders, connectionText) - ?: getPendingOrdersAsChannels(channels, paidOrders) - .find { it.channelId == channelId } - ?.mapToUiModel(channels, paidOrders, connectionText) - ?: getFailedOrdersAsChannels(paidOrders) - .find { it.channelId == channelId } - ?.mapToUiModel(channels, paidOrders, connectionText) + private fun resolveActiveChannel(channelId: String, channels: List): ChannelUi? = + channels.find { it.channelId == channelId }?.mapToUiModel(channels, connectionText) private suspend fun loadClosedChannels(): List { val channels = lightningRepo.lightningState.value.channels - val paidOrders = blocktankRepo.blocktankState.value.paidOrders return activityRepo.getClosedChannels(SortDirection.DESC) .onFailure { Logger.error("Failed to load closed channels", it, context = TAG) } .getOrNull() ?.mapIndexed { index, closedChannel -> closedChannel.toChannelUi( - baseIndex = channels.size + getPendingOrdersAsChannels(channels, paidOrders).size + index, + baseIndex = channels.size + index, connectionText = connectionText, ) } @@ -167,14 +155,14 @@ class ChannelDetailViewModel @Inject constructor( blocktankRepo.blocktankState, ) { lightningState, blocktankState -> val channels = lightningState.channels - val updatedChannel = resolveActiveChannel(channelId, channels, blocktankState.paidOrders) + val updatedChannel = resolveActiveChannel(channelId, channels) Pair(updatedChannel, blocktankState) }.collect { (updatedChannel, blocktankState) -> if (updatedChannel != null) { _uiState.update { it.copy( channelLoadState = ChannelLoadState.Success(updatedChannel), - paidOrders = blocktankState.paidOrders, + orders = blocktankState.orders, cjitEntries = blocktankState.cjitEntries, nodeId = lightningRepo.getNodeId().orEmpty(), ) @@ -189,7 +177,7 @@ class ChannelDetailViewModel @Inject constructor( it.copy( channelLoadState = ChannelLoadState.Success(closedChannel), isClosedChannel = true, - paidOrders = blocktankState.paidOrders, + orders = blocktankState.orders, cjitEntries = blocktankState.cjitEntries, nodeId = lightningRepo.getNodeId().orEmpty(), ) @@ -206,7 +194,7 @@ class ChannelDetailViewModel @Inject constructor( @Stable data class ChannelDetailUiState( val channelLoadState: ChannelLoadState = ChannelLoadState.Loading, - val paidOrders: ImmutableList = persistentListOf(), + val orders: ImmutableList = persistentListOf(), val cjitEntries: ImmutableList = persistentListOf(), val txTime: ULong? = null, val isRefreshing: Boolean = false, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 84ec1944a8..cbeebe55e3 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -206,19 +206,6 @@ private fun Content( ) } - // Closed & Failed Channels Section - AnimatedVisibility(visible = showClosed && uiState.failedOrders.isNotEmpty()) { - Column { - VerticalSpacer(16.dp) - Caption13Up(stringResource(R.string.lightning__conn_failed), color = Colors.White64) - ChannelList( - status = ChannelStatusUi.CLOSED, - channels = uiState.failedOrders, - onClickChannel = onClickChannel, - ) - } - } - // Closed Channels Section AnimatedVisibility(visible = showClosed && uiState.closedChannels.isNotEmpty()) { Column { @@ -233,7 +220,7 @@ private fun Content( } // Show/Hide Closed Channels Button - if (uiState.failedOrders.isNotEmpty() || uiState.closedChannels.isNotEmpty()) { + if (uiState.closedChannels.isNotEmpty()) { VerticalSpacer(16.dp) TertiaryButton( text = stringResource( @@ -451,28 +438,6 @@ private fun Preview() { inboundCapacityMsat = 700_000_000u, ), ), - ).toImmutableList(), - failedOrders = listOf( - ChannelUi( - name = "Connection 4", - details = createChannelDetails().copy( - channelId = "failed_order_1", - channelValueSats = 200_000u, - outboundCapacityMsat = 50_000_000u, - inboundCapacityMsat = 150_000_000u, - isChannelReady = false, - isUsable = false, - ), - ), - ChannelUi( - name = "Connection 5", - details = createChannelDetails().copy( - channelId = "failed_order_2", - channelValueSats = 100_000u, - outboundCapacityMsat = 30_000_000u, - inboundCapacityMsat = 70_000_000u, - ), - ), ).toImmutableList() ) ) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 74bc1d85be..6c0b6ce6a4 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -5,9 +5,7 @@ import android.net.Uri import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.ClosedChannelDetails -import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.SortDirection import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -18,7 +16,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails @@ -32,7 +29,6 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.filterOpen import to.bitkit.ext.filterPending import to.bitkit.models.Toast -import to.bitkit.models.safe import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo @@ -71,8 +67,7 @@ class LightningConnectionsViewModel @Inject constructor( .onSuccess { closedChannels -> val channels = lightningRepo.lightningState.value.channels val openChannels = channels.filterOpen() - val pendingConnections = - getPendingConnections(channels, blocktankRepo.blocktankState.value.paidOrders) + val pendingConnections = channels.filterPending() _uiState.update { state -> state.copy( @@ -93,12 +88,7 @@ class LightningConnectionsViewModel @Inject constructor( private fun observeState() { viewModelScope.launch(bgDispatcher) { - combine( - lightningRepo.lightningState, - blocktankRepo.blocktankState, - ) { lightningState, blocktankState -> - Pair(lightningState, blocktankState) - }.collect { (lightningState, blocktankState) -> + lightningRepo.lightningState.collect { lightningState -> val channels = lightningState.channels val connectionText = context.getString(R.string.lightning__connection) @@ -106,13 +96,10 @@ class LightningConnectionsViewModel @Inject constructor( it.copy( isNodeRunning = lightningState.nodeLifecycleState.isRunning(), openChannels = channels.filterOpen().map { channel -> - channel.mapToUiModel(channels, blocktankState.paidOrders, connectionText) + channel.mapToUiModel(channels, connectionText) }.reversed().toImmutableList(), - pendingConnections = getPendingConnections(channels, blocktankState.paidOrders) - .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) } - .reversed().toImmutableList(), - failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders) - .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) } + pendingConnections = channels.filterPending() + .map { it.mapToUiModel(channels, connectionText) } .reversed().toImmutableList(), localBalance = calculateLocalBalance(channels), remoteBalance = channels.calculateRemoteBalance(), @@ -154,16 +141,6 @@ class LightningConnectionsViewModel @Inject constructor( loadClosedChannels() } - private fun getPendingConnections( - knownChannels: List, - paidOrders: List, - ): List { - val pendingLdkChannels = knownChannels.filterPending() - val pendingOrderChannels = getPendingOrdersAsChannels(knownChannels, paidOrders) - - return pendingOrderChannels + pendingLdkChannels - } - private fun calculateLocalBalance(channels: List): ULong { return channels .filterOpen() @@ -185,85 +162,21 @@ class LightningConnectionsViewModel @Inject constructor( } } -internal fun getPendingOrdersAsChannels( - knownChannels: List, - paidOrders: List, -): List { - return paidOrders.mapNotNull { order -> - if (knownChannels.any { channel -> channel.fundingTxo?.txid == order.channel?.fundingTx?.id }) { - return@mapNotNull null - } - - if (order.state2 !in listOf(BtOrderState2.CREATED, BtOrderState2.PAID)) return@mapNotNull null - - createChannelDetails().copy( - channelId = order.id, - counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), - fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, - channelValueSats = order.clientBalanceSat.safe() + order.lspBalanceSat.safe(), - outboundCapacityMsat = order.clientBalanceSat * 1000u, - inboundCapacityMsat = order.lspBalanceSat * 1000u, - ) - } -} - -internal fun getFailedOrdersAsChannels( - paidOrders: List, -): List { - return paidOrders.mapNotNull { order -> - if (order.state2 != BtOrderState2.EXPIRED) return@mapNotNull null - - createChannelDetails().copy( - channelId = order.id, - counterpartyNodeId = order.lspNode?.pubkey.orEmpty(), - fundingTxo = order.channel?.fundingTx?.let { OutPoint(txid = it.id, vout = it.vout.toUInt()) }, - channelValueSats = order.clientBalanceSat.safe() + order.lspBalanceSat.safe(), - outboundCapacityMsat = order.clientBalanceSat * 1000u, - inboundCapacityMsat = order.lspBalanceSat * 1000u, - isChannelReady = false, - isUsable = false, - ) - } -} - -@Suppress("ForbiddenComment") internal fun getChannelName( channel: ChannelDetails, allChannels: List, - paidOrders: List, connectionText: String, ): String { - val default = channel.inboundScidAlias?.toString() ?: "${channel.channelId.take(10)}…" - - // orders without a corresponding known channel are considered pending - val pendingChannels = paidOrders.filter { order -> - allChannels.none { ch -> ch.fundingTxo?.txid == order.channel?.fundingTx?.id } - } - val pendingIndex = pendingChannels.indexOfFirst { order -> channel.channelId == order.id } - - // TODO: sort channels to get consistent index; node.listChannels returns a list in random order val channelIndex = allChannels.indexOfFirst { channel.channelId == it.channelId } - - return when { - channelIndex == -1 -> { - if (pendingIndex == -1) { - default - } else { - val index = allChannels.size + pendingIndex - "$connectionText ${index + 1}" - } - } - - else -> "$connectionText ${channelIndex + 1}" - } + if (channelIndex == -1) return channel.inboundScidAlias?.toString() ?: "${channel.channelId.take(10)}…" + return "$connectionText ${channelIndex + 1}" } internal fun ChannelDetails.mapToUiModel( allChannels: List, - paidOrders: List, connectionText: String, ): ChannelUi = ChannelUi( - name = getChannelName(this, allChannels, paidOrders, connectionText), + name = getChannelName(this, allChannels, connectionText), details = this, ) @@ -296,7 +209,6 @@ data class LightningConnectionsUiState( val isRefreshing: Boolean = false, val openChannels: ImmutableList = persistentListOf(), val pendingConnections: ImmutableList = persistentListOf(), - val failedOrders: ImmutableList = persistentListOf(), val closedChannels: ImmutableList = persistentListOf(), val localBalance: ULong = 0u, val remoteBalance: ULong = 0u, diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 6ae7003a66..8e351ba227 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -40,19 +40,13 @@ class DeriveBalanceStateUseCase @Inject constructor( val channels = lightningRepo.getChannels().orEmpty() val activeTransfers = transferRepo.activeTransfers.first() - val paidOrdersSats = getOrderPaymentsSats(activeTransfers, channels, balanceDetails) val pendingChannelsSats = getPendingChannelsSats(activeTransfers, channels, balanceDetails) val toSavingsAmount = getTransferToSavingsSats(activeTransfers, channels, balanceDetails) val coopCloseSavingsSats = getCoopCloseTransferSats(activeTransfers, channels, balanceDetails) val lingeringCoopCloseSats = getLingeringCoopCloseSats(activeTransfers, channels, balanceDetails) - val toSpendingAmount = paidOrdersSats.safe() + pendingChannelsSats.safe() - val orderPaymentsOnchainToSubtract = getOrderPaymentOnchainToSubtract( - activeTransfers = activeTransfers, - currentOnchainSats = balanceDetails.totalOnchainBalanceSats, - ) - val totalOnchainSats = balanceDetails.totalOnchainBalanceSats.safe() - orderPaymentsOnchainToSubtract.safe() + val totalOnchainSats = balanceDetails.totalOnchainBalanceSats val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance()) val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe() @@ -65,7 +59,7 @@ class DeriveBalanceStateUseCase @Inject constructor( maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), maxSendOnchainSats = getMaxSendAmount(balanceDetails), balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), - balanceInTransferToSpending = toSpendingAmount, + balanceInTransferToSpending = pendingChannelsSats, hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, ) @@ -78,48 +72,6 @@ class DeriveBalanceStateUseCase @Inject constructor( } } - private suspend fun getOrderPaymentsSats( - transfers: List, - channels: List, - balances: BalanceDetails, - ): ULong { - var amount = 0uL - val paidOrders = transfers.filter { it.type.isToSpending() && it.lspOrderId != null } - - for (transfer in paidOrders) { - val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) - val channelBalance = channelId?.let { id -> - balances.lightningBalances.find { it.channelId() == id } - } - if (channelBalance == null) { - amount = amount.safe() + transfer.amountSats.toULong().safe() - } - } - - return amount - } - - private fun getOrderPaymentOnchainToSubtract( - activeTransfers: List, - currentOnchainSats: ULong, - ): ULong { - var amount = 0uL - val orderPayments = activeTransfers.filter { - it.type.isToSpending() && it.fundingTxId != null && it.lspOrderId != null - } - - for (transfer in orderPayments) { - val txTotalSats = transfer.txTotalSats?.takeIf { it > 0 }?.toULong() - val preTransferOnchainSats = transfer.preTransferOnchainSats?.takeIf { it > 0 }?.toULong() - - if (txTotalSats != null && preTransferOnchainSats != null && currentOnchainSats >= preTransferOnchainSats) { - amount = amount.safe() + txTotalSats.safe() - } - } - - return amount - } - private suspend fun getPendingChannelsSats( transfers: List, channels: List, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ddb119a4f4..8b6fb158fd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -6093,8 +6093,6 @@ class AppViewModel @Inject constructor( val winner = lightningRepo.currentOnchainSendAttempt() ?: return@runSuspendCatching if (request != null) { paykitPaymentProofRepo.completeRecoveredOnchainPayment(request, winner) - } else if (winner.isTransfer) { - transferRepo.resumeAcceptedFunding().getOrThrow() } else { lightningRepo.completeAcceptedOrdinaryFollowup(requireNotNull(winner.txid)) } diff --git a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt index 45c2169475..281994b674 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt @@ -42,8 +42,4 @@ class BlocktankViewModel @Inject constructor( blocktankRepo.refreshOrders() } } - - suspend fun openChannel(orderId: String): IBtOrder { - return blocktankRepo.openChannel(orderId).getOrThrow() - } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c5a866c6ca..459ee338cc 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -118,7 +118,6 @@ الاتصالات المغلقة إخفاء المغلقة والفاشلة إظهار المغلقة والفاشلة - الاتصالات الفاشلة الاتصالات المفتوحة الاتصالات المعلقة الاتصال 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 6de031f75f..efc9e1a454 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -123,7 +123,6 @@ Conexiones cerradas Ocultar Cerrado y Fallido Mostrar cerrado y fallido - Conexiones falladas Conexiones abiertas Conexiones pendientes Conexión diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 4ed3e2fcec..78aa507505 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -121,7 +121,6 @@ Connexions tancades Amaga tancades i fallides Mostra tancades i fallides - Connexions fallides Connexions obertes Connexions pendents Connexió diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index d6a1fd389e..3f071da34e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -123,7 +123,6 @@ Uzavřená spojení Skrýt uzavřená & nepovedená Ukázat uzavřená & nepovedená - Neprovedená spojení Otevřená spojení Nevyřízená spojení Spojení diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 81ab795b4f..ee496be8fa 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -157,7 +157,6 @@ Ausstehende Verbindungen Offene Verbindungen Geschlossene Verbindungen - Fehlgeschlagene Verbindungen Geschlossene & Fehlgeschlagene anzeigen Geschlossene & Fehlgeschlagene ausblenden Logs exportieren diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 73ccc37dc1..e8046092f2 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -118,7 +118,6 @@ Κλειστές συνδέσεις Απόκρυψη κλειστών & αποτυχημένων Εμφάνιση κλειστών & αποτυχημένων - Αποτυχημένες συνδέσεις Ανοιχτές συνδέσεις Εκκρεμείς συνδέσεις Σύνδεση diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index fd4d42acfc..c63aa9c6ac 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -123,7 +123,6 @@ Conexiones cerradas Ocultar cerrados y fallidos Mostrar cerrados y fallidos - Conexiones fallidas Conexiones abiertas Conexiones pendientes Conexión diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 09d3b0329b..5a17e4a1eb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -199,7 +199,6 @@ Conexiones pendientes Conexiones abiertas Conexiones cerradas - Conexiones fallidas Exportar registros Añadir conexión Bitkit no fue capaz de exportar los logs LDK. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index dd756b63df..5c62af16be 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -166,7 +166,6 @@ Connexions en attente Connexions ouvertes Connexions fermées - Échec des connexions Montrer les fermetures et les échecs Cachez les fermetures et les échecs Exporter les logs diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3ae6a58772..32d7629534 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -122,7 +122,6 @@ Connessioni chiuse Nascondi Chiuse e Fallite Mostra Chiuse e Fallite - Connessioni fallite Connessioni aperte Connessioni in sospeso Connessione diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 04e4202803..a9c5dc3685 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -122,7 +122,6 @@ Gesloten verbindingen Verberg gesloten & mislukt Toon gesloten & mislukt - Mislukte verbindingen Open verbindingen Wachtende verbindingen Verbinding diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 599e844364..e4caffd391 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -159,7 +159,6 @@ Połączenia oczekujące Połączenia otwarte Połączenia zamknięte - Połączenia nieudane Pokaż zamknięte i nieudane Ukryj zamknięte i nieudane Eksport logów diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 95e064b729..1837fcf3b9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -124,7 +124,6 @@ Conexões fechadas Ocultar Fechadas e com Falhas Mostrar Fechadas e com Falhas - Conexões com falha Conexões abertas Conexões pendentes Conexão diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 7c6c20fe77..7b1ee65fea 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -153,7 +153,6 @@ Conexões pendentes Conexões abertas Conexões fechadas - Conexões com falha Mostrar Fechadas e com Falhas Ocultar Fechadas e com Falhas Exportar Logs diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index cc393f54a1..c80dea25c0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -132,7 +132,6 @@ Закрытые Соединения Скрыть Закрытые и Неудачные Показать Закрытые и Неудачные - Неудачные Соединения Открытые Соединения Ожидающие Соединения Соединение diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26c3ba9446..735b541868 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -269,7 +269,6 @@ Closed connections Hide Closed & Failed Show Closed & Failed - Failed connections Open connections Pending connections Connection diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt index 0f0a540ac7..f0d247287b 100644 --- a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -53,7 +53,6 @@ class AppCacheDataTest : BaseUnitTest() { bolt11PaymentHash = "paymentHash", bip21 = "bitcoin:bc1qtest?lightning=paidInvoice", balance = BalanceState(totalOnchainSats = 42uL), - paidOrders = mapOf("order" to "txid"), ) val invalidated = cache.invalidateReceiveLightningInvoice() @@ -63,7 +62,6 @@ class AppCacheDataTest : BaseUnitTest() { assertEquals("", invalidated.bip21) assertEquals(cache.onchainAddress, invalidated.onchainAddress) assertEquals(cache.balance, invalidated.balance) - assertEquals(cache.paidOrders, invalidated.paidOrders) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 936667e78d..1a0a1a14f2 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -108,7 +108,6 @@ class BackupRepoTest : BaseUnitTest() { private val paykitPresentationStore = mock() private val keychain = mock() private val onchainSendAttemptStore = mock() - private val transferRepo = mock() private val vssStoreIdProvider = mock() private val preActivityMetadataRepo = mock() private val lightningService = mock() @@ -320,7 +319,7 @@ class BackupRepoTest : BaseUnitTest() { } @Test - fun `restored accepted funding resumes after all original context is installed without a node transition`() = test { + fun `restored accepted Blocktank funding finishes through the ordinary follow-up`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val originalWire = requireNotNull(state.activeOnchainAttempt) @@ -339,25 +338,15 @@ class BackupRepoTest : BaseUnitTest() { val wire = to.bitkit.models.ActiveOnchainAttemptBackup.from(funding, "regtest", originalWire.wallet.binding) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(originalWire.wallet.binding) whenever(onchainSendAttemptStore.current()).thenReturn(funding) - whenever(transferRepo.resumeAcceptedFunding(any())).thenReturn(Result.success(Unit)) stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) sut.performFullRestoreFromLatestBackup().getOrThrow() - val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, transferDao, privatePaykitRepo, transferRepo) + val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, transferDao, privatePaykitRepo, lightningRepo) ordered.verify(onchainSendAttemptStore).restoreActive( wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) ) ordered.verify(transferDao).upsert(any>()) ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) - ordered.verify(transferRepo).resumeAcceptedFunding( - wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) - ) - // Local follow-up failure preserves the restored guard and does not turn restore into a payment failure. - whenever(transferRepo.resumeAcceptedFunding(any())).thenReturn(Result.failure(AppError("storage unavailable"))) - sut.performFullRestoreFromLatestBackup().getOrThrow() - verify(onchainSendAttemptStore, times(2)).restoreActive( - wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) - ) - verify(transferRepo, times(2)).resumeAcceptedFunding(any()) + ordered.verify(lightningRepo).completeAcceptedOrdinaryFollowup(requireNotNull(funding.txid)) whenever(onchainSendAttemptStore.current()).thenReturn(funding.copy(evidence = OnchainSendEvidence.Unknown)) stubWalletBackup( paykitPaymentState = state.copy( @@ -366,7 +355,7 @@ class BackupRepoTest : BaseUnitTest() { ) ) sut.performFullRestoreFromLatestBackup().getOrThrow() - verify(transferRepo, times(2)).resumeAcceptedFunding(any()) + verify(lightningRepo, times(1)).completeAcceptedOrdinaryFollowup(any()) } @Test @@ -790,7 +779,7 @@ class BackupRepoTest : BaseUnitTest() { assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) verify(onchainSendAttemptStore, never()).restoreActive(any()) verify(paykitPaymentProofRepo, never()).restoreBackup(any()) - verify(transferRepo, never()).resumeAcceptedFunding(any()) + verify(lightningRepo, never()).completeAcceptedOrdinaryFollowup(any()) verify(vssBackupClient, never()).putObject(any(), any()) } @@ -2071,7 +2060,6 @@ class BackupRepoTest : BaseUnitTest() { paykitPresentationStore = paykitPresentationStore, keychain = keychain, onchainSendAttemptStore = onchainSendAttemptStore, - transferRepo = Provider { transferRepo }, lightningRepo = Provider { lightningRepo }, vssStoreIdProvider = vssStoreIdProvider, preActivityMetadataRepo = preActivityMetadataRepo, diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 9bd204f94b..071001b136 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -7,19 +7,13 @@ import com.synonym.bitkitcore.IBtChannel import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.OutPoint -import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.data.AppCacheData -import to.bitkit.data.CacheStore import to.bitkit.models.BlocktankBackupV1 import to.bitkit.services.BlocktankService import to.bitkit.services.CoreService @@ -32,8 +26,6 @@ class BlocktankRepoTest : BaseUnitTest() { private val coreService: CoreService = mock() private val blocktankService: BlocktankService = mock() - private val cacheStore: CacheStore = mock() - private val cacheData = MutableStateFlow(AppCacheData()) private lateinit var sut: BlocktankRepo @@ -41,12 +33,6 @@ class BlocktankRepoTest : BaseUnitTest() { @Before fun setUp() { - cacheData.value = AppCacheData() - whenever(cacheStore.data).thenReturn(cacheData) - whenever { cacheStore.update(any()) }.thenAnswer { - val transform = it.getArgument<(AppCacheData) -> AppCacheData>(0) - cacheData.value = transform(cacheData.value) - } whenever(coreService.blocktank).thenReturn(blocktankService) whenever { blocktankService.info(refresh = false) }.thenReturn(mock()) @@ -63,7 +49,6 @@ class BlocktankRepoTest : BaseUnitTest() { return BlocktankRepo( bgDispatcher = testDispatcher, coreService = coreService, - cacheStore = cacheStore, enablePolling = false, ) } @@ -115,51 +100,6 @@ class BlocktankRepoTest : BaseUnitTest() { } } - @Test - fun `refreshOrders filters paid orders based on cacheStore data`() = test { - sut = createSut() - val expectedOrders = listOf(testOrder1) - whenever { coreService.blocktank.orders(refresh = true) }.thenReturn(expectedOrders) - val orderId = "order1" - whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(paidOrders = mapOf(orderId to "txId")))) - - sut.blocktankState.test { - awaitItem() // Skip initial state - - sut.refreshOrders() - - val state = awaitItem() - assertEquals(expectedOrders, state.orders) - assertEquals(1, state.paidOrders.size) - assertEquals(orderId, state.paidOrders.first().id) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `openChannel updates state after open`() = test { - whenever { coreService.blocktank.orders(refresh = true) }.thenReturn(listOf(testOrder1)) - sut = createSut() - val orderId = "order1" - val updatedOrder = mock().apply { whenever(id).thenReturn(orderId) } - whenever { coreService.blocktank.open(orderId) }.thenReturn(updatedOrder) - - sut.blocktankState.test { - awaitItem() // Skip initial state - - val result = sut.openChannel(orderId) - verify(coreService.blocktank).open(orderId) - - assertTrue(result.isSuccess) - assertEquals(updatedOrder, result.getOrThrow()) - - // // Verify state was updated - val state = awaitItem() - assertEquals(updatedOrder, state.orders.first { it.id == orderId }) - cancelAndIgnoreRemainingEvents() - } - } - @Test fun `getOrder returns order from state after refresh`() = test { sut = createSut() diff --git a/app/src/test/java/to/bitkit/repositories/HealthRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HealthRepoTest.kt index eac059a62b..0328b391dc 100644 --- a/app/src/test/java/to/bitkit/repositories/HealthRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HealthRepoTest.kt @@ -1,7 +1,6 @@ package to.bitkit.repositories import app.cash.turbine.test -import com.synonym.bitkitcore.IBtOrder import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow @@ -28,7 +27,6 @@ import kotlin.time.Instant class HealthRepoTest : BaseUnitTest() { private val connectivityRepo = mock() private val lightningRepo = mock() - private val blocktankRepo = mock() private val cacheStore = mock() private val clock: Clock = mock() @@ -40,7 +38,6 @@ class HealthRepoTest : BaseUnitTest() { fun setUp() { whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) - whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(cacheStore.backupStatuses).thenReturn(flowOf(emptyMap())) whenever(clock.now()).thenReturn(fixedTime) } @@ -50,7 +47,6 @@ class HealthRepoTest : BaseUnitTest() { bgDispatcher = testDispatcher, connectivityRepo = connectivityRepo, lightningRepo = lightningRepo, - blocktankRepo = blocktankRepo, cacheStore = cacheStore, clock = clock, ) @@ -206,27 +202,6 @@ class HealthRepoTest : BaseUnitTest() { } } - @Test - fun `paid orders override channels error to pending`() = test { - val lightningState = LightningState( - nodeLifecycleState = NodeLifecycleState.Running, - channels = persistentListOf(), - ) - whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(lightningState)) - - val paidOrder = mock() - val blocktankState = BlocktankState(paidOrders = listOf(paidOrder).toImmutableList()) - whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(blocktankState)) - - sut = createSut() - - sut.healthState.test { - val state = awaitItem() - assertEquals(HealthState.PENDING, state.channels) - cancelAndIgnoreRemainingEvents() - } - } - @Test fun `lightning node state maps correctly when online`() = test { val lightningState = LightningState( diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 536c7743e2..04ef8a4a95 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -2594,7 +2594,7 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `accepted transfer acknowledgement waits for original durable local activity`() = test { + fun `accepted Blocktank funding finishes through the ordinary follow-up once its activity is durable`() = test { val txid = "ab".repeat(32) val attempt = pendingSendAttempt().copy( isTransfer = true, @@ -2607,10 +2607,10 @@ class LightningRepoTest : BaseUnitTest() { whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) whenever(onchainSendAttemptStore.current()).thenReturn(attempt) - assertTrue(runCatching { sut.completeAcceptedTransferFollowup("order-1", txid) }.isFailure) + sut.completeAcceptedOrdinaryFollowup(txid) verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) - sut.completeAcceptedTransferFollowup("order-1", txid) + sut.completeAcceptedOrdinaryFollowup(txid) verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) verify(lightningService, never()).prepareOnchainSend( any(), diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 4032714976..13d6899a51 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -476,7 +476,6 @@ class QuickPayRepoTest : BaseUnitTest() { cacheStore.update { AppCacheData( onchainAddress = "keep-me", - paidOrders = mapOf("order" to "tx"), quickPayLedger = QuickPayLedger( version = 99, dayKey = "2026-08-15", @@ -490,7 +489,6 @@ class QuickPayRepoTest : BaseUnitTest() { assertFalse(sut.canApply(500u).getOrThrow()) val data = cacheStore.data.first() assertEquals("keep-me", data.onchainAddress) - assertEquals(mapOf("order" to "tx"), data.paidOrders) assertEquals(99, data.quickPayLedger?.version) assertEquals(999L, data.quickPayLedger?.spentCents) } diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 16f4478e61..82b7f67b70 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -12,12 +12,10 @@ import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.BalanceDetails @@ -36,14 +34,11 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.data.AppCacheData -import to.bitkit.data.CacheStore import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.env.Env import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails -import to.bitkit.models.NodeLifecycleState import to.bitkit.models.TransferType import to.bitkit.models.WalletScope import to.bitkit.services.ActivityService @@ -70,10 +65,7 @@ class TransferRepoTest : BaseUnitTest() { on { activity } doReturn activityService } private val clock = mock() - private val cacheStore = mock() private val nodeEvents = MutableSharedFlow() - private val online = MutableStateFlow(ConnectivityState.DISCONNECTED) - private val connectivityRepo = mock() companion object Fixtures { private const val ID_ORDER = "test-order-id" @@ -84,8 +76,6 @@ class TransferRepoTest : BaseUnitTest() { @Before fun setUp() { - whenever(connectivityRepo.isOnline).thenReturn(online) - whenever(cacheStore.data).thenReturn(MutableStateFlow(AppCacheData())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(emptyList())) @@ -97,396 +87,9 @@ class TransferRepoTest : BaseUnitTest() { coreService = coreService, transferDao = transferDao, clock = clock, - cacheStore = cacheStore, - connectivityRepo = connectivityRepo, ) } - @Test - fun `accepted funding rejects changed original client balance and fee without acknowledgement or resend`() = test { - val original = previewBtOrder() - val txid = "ab".repeat(32) - val attempt = OnchainSendAttempt( - WalletScope.default, "attempt", null, original.id, - requireNotNull(original.payment?.onchain?.address), original.feeSat, false, 1uL, true, null, emptyList(), - OnchainSendEvidence.Accepted, txid, - transferContext = OnchainTransferContext(99_000uL, 125_000uL, original.clientBalanceSat, original.feeSat), - ) - setupClockNowMock() - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - for (changed in listOf( - original.copy(clientBalanceSat = original.clientBalanceSat + 1uL), - original.copy(feeSat = original.feeSat + 1uL), - )) { - whenever(blocktankRepo.fetchOrders(listOf(original.id))).thenReturn(Result.success(listOf(changed))) - assertTrue(sut.resumeAcceptedFunding().isFailure) - } - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( - attempt.copy(transferContext = OnchainTransferContext(99_000uL, 125_000uL)) - ) - whenever(blocktankRepo.fetchOrders(listOf(original.id))).thenReturn(Result.success(listOf(original))) - assertTrue(sut.resumeAcceptedFunding().isFailure) - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - assertTrue(sut.resumeAcceptedFunding(attempt.copy(walletIndex = 1)).isFailure) - verify(transferDao, never()).insert(any()) - verify(cacheStore, never()).addPaidOrder(any(), any()) - verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) - verify(lightningRepo, never()).sendOnChain( - any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), - anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - assertEquals(attempt, lightningRepo.currentOnchainSendAttempt()) - } - - @Test - fun `connectivity recovery completes accepted funding without another send`() = test { - val order = previewBtOrder() - val txid = "ab".repeat(32) - val attempt = OnchainSendAttempt( - WalletScope.default, - "attempt", - null, - order.id, - requireNotNull(order.payment?.onchain?.address), - order.feeSat, - false, - 1uL, - true, - null, - emptyList(), - OnchainSendEvidence.Accepted, - txid, - transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), - ) - setupClockNowMock() - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - whenever(blocktankRepo.fetchOrders(listOf(order.id))) - .thenReturn(Result.failure(AppError("offline"))) - whenever(lightningRepo.lightningState).thenReturn( - MutableStateFlow( - LightningState( - nodeLifecycleState = NodeLifecycleState.Running, - ) - ) - ) - runCurrent() - assertTrue(sut.resumeAcceptedFunding().isFailure) - verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) - whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) - online.value = ConnectivityState.CONNECTED - runCurrent() - - verify(transferDao).insert( - org.mockito.kotlin.check { - assertEquals(order.id, it.lspOrderId) - assertEquals(txid, it.fundingTxId) - assertEquals(99_000L, it.txTotalSats) - assertEquals(125_000L, it.preTransferOnchainSats) - } - ) - verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) - verify(lightningRepo, never()).sendOnChain( - any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), - anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - assertNotNull(sut) - } - - @Test - fun `reconnect during suspended funding recovery starts another attempt`() = test { - val order = previewBtOrder() - val txid = "ab".repeat(32) - val attempt = OnchainSendAttempt( - WalletScope.default, - "attempt", - null, - order.id, - requireNotNull(order.payment?.onchain?.address), - order.feeSat, - false, - 1uL, - true, - null, - emptyList(), - OnchainSendEvidence.Accepted, - txid, - transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), - ) - setupClockNowMock() - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - val firstFetch = CompletableDeferred() - var fetches = 0 - whenever(blocktankRepo.fetchOrders(listOf(order.id))).doSuspendableAnswer { - fetches += 1 - if (fetches == 1) { - firstFetch.await() - Result.failure(AppError("offline")) - } else { - Result.success(listOf(order)) - } - } - whenever(lightningRepo.lightningState).thenReturn( - MutableStateFlow( - LightningState( - nodeLifecycleState = NodeLifecycleState.Running, - ) - ) - ) - runCurrent() - online.value = ConnectivityState.CONNECTED - runCurrent() - assertEquals(1, fetches) - online.value = ConnectivityState.DISCONNECTED - runCurrent() - online.value = ConnectivityState.CONNECTED - runCurrent() - firstFetch.complete(Unit) - runCurrent() - assertEquals(2, fetches) - - verify(transferDao).insert( - org.mockito.kotlin.check { - assertEquals(order.id, it.lspOrderId) - assertEquals(txid, it.fundingTxId) - assertEquals(99_000L, it.txTotalSats) - assertEquals(125_000L, it.preTransferOnchainSats) - } - ) - verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) - verify(lightningRepo, never()).sendOnChain( - any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), - anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - assertNotNull(sut) - } - - @Test - fun `node restart during suspended funding recovery starts another attempt`() = test { - val order = previewBtOrder() - val txid = "ab".repeat(32) - val attempt = OnchainSendAttempt( - WalletScope.default, - "attempt", - null, - order.id, - requireNotNull(order.payment?.onchain?.address), - order.feeSat, - false, - 1uL, - true, - null, - emptyList(), - OnchainSendEvidence.Accepted, - txid, - transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), - ) - setupClockNowMock() - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - val firstFetch = CompletableDeferred() - var fetches = 0 - whenever(blocktankRepo.fetchOrders(listOf(order.id))).doSuspendableAnswer { - fetches += 1 - if (fetches == 1) { - firstFetch.await() - Result.failure(AppError("offline")) - } else { - Result.success(listOf(order)) - } - } - val state = MutableStateFlow(LightningState()) - whenever(lightningRepo.lightningState).thenReturn(state) - val restarted = TransferRepo( - testDispatcher, - lightningRepo, - blocktankRepo, - coreService, - transferDao, - clock, - cacheStore, - connectivityRepo, - ) - runCurrent() - state.value = state.value.copy(nodeLifecycleState = NodeLifecycleState.Running) - runCurrent() - assertEquals(1, fetches) - state.value = state.value.copy(nodeLifecycleState = NodeLifecycleState.Stopped) - runCurrent() - state.value = state.value.copy(nodeLifecycleState = NodeLifecycleState.Running) - runCurrent() - firstFetch.complete(Unit) - runCurrent() - assertEquals(2, fetches) - - verify(transferDao).insert( - org.mockito.kotlin.check { - assertEquals(order.id, it.lspOrderId) - assertEquals(txid, it.fundingTxId) - assertEquals(99_000L, it.txTotalSats) - assertEquals(125_000L, it.preTransferOnchainSats) - } - ) - verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) - verify(lightningRepo, never()).sendOnChain( - any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), - anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - assertNotNull(restarted) - } - - @Test - fun `startup resumes accepted original funding without a confirmation or native resend`() = test { - val order = previewBtOrder() - val txid = "ab".repeat(32) - val attempt = OnchainSendAttempt( - WalletScope.default, - "attempt", - null, - order.id, - requireNotNull(order.payment?.onchain?.address), - order.feeSat, - false, - 1uL, - true, - null, - emptyList(), - OnchainSendEvidence.Accepted, - txid, - transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), - ) - setupClockNowMock() - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) - whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) - whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) - // Construct after the saved result exists, as on process startup. - val restarted = - TransferRepo( - testDispatcher, - lightningRepo, - blocktankRepo, - coreService, - transferDao, - clock, - cacheStore, - connectivityRepo - ) - runCurrent() - lightningRepo.lightningState.value.let { - (lightningRepo.lightningState as MutableStateFlow).value = it.copy( - nodeLifecycleState = NodeLifecycleState.Running, - ) - } - runCurrent() - - verify(transferDao).insert( - org.mockito.kotlin.check { - assertEquals(order.id, it.lspOrderId) - assertEquals(txid, it.fundingTxId) - assertEquals(99_000L, it.txTotalSats) - assertEquals(125_000L, it.preTransferOnchainSats) - } - ) - verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) - verify(lightningRepo, never()).sendOnChain( - any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), - anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - assertNotNull(restarted) - } - - @Test - fun `exact observed event retries partial original funding without duplicate transfer`() = test { - val order = previewBtOrder() - val txid = "ab".repeat(32) - val attempt = OnchainSendAttempt( - WalletScope.default, - "attempt", - null, - order.id, - requireNotNull(order.payment?.onchain?.address), - order.feeSat, - false, - 1uL, - true, - null, - emptyList(), - OnchainSendEvidence.Observed, - txid, - transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), - ) - setupClockNowMock() - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) - var persisted: TransferEntity? = null - whenever(transferDao.getByFundingTxId(txid)).thenAnswer { persisted } - whenever(transferDao.insert(any())).doSuspendableAnswer { - persisted = it.getArgument(0) - Unit - } - var failCache = true - whenever(cacheStore.addPaidOrder(order.id, txid)).doSuspendableAnswer { - if (failCache) { - failCache = false - error("cache storage unavailable") - } - Unit - } - assertTrue(sut.resumeAcceptedFunding().isFailure) - verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) - - nodeEvents.emit(Event.OnchainTransactionReceived(txid, mock())) - runCurrent() - verify(transferDao, times(1)).insert(any()) - verify(cacheStore, times(2)).addPaidOrder(order.id, txid) - verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) - assertEquals(99_000L, persisted?.txTotalSats) - assertEquals(125_000L, persisted?.preTransferOnchainSats) - verify(lightningRepo, never()).sendOnChain( - any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), - anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), - paymentDeadlineAt = anyOrNull(), - contactPublicKey = anyOrNull(), - ) - } - - @Test - fun `unknown funding cannot resume from local records or another transaction event`() = test { - val order = previewBtOrder() - val attempt = OnchainSendAttempt( - WalletScope.default, - "attempt", - null, - order.id, - requireNotNull(order.payment?.onchain?.address), - order.feeSat, - false, - 1uL, - true, - null, - emptyList(), - OnchainSendEvidence.Unknown, - "ab".repeat(32), - transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), - ) - whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) - nodeEvents.emit(Event.OnchainTransactionReceived("cd".repeat(32), mock())) - runCurrent() - verify(transferDao, never()).insert(any()) - verify(cacheStore, never()).addPaidOrder(any(), any()) - verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) - org.mockito.kotlin.verifyNoInteractions(blocktankRepo) - } - // MARK: - createTransfer @Test @@ -597,31 +200,6 @@ class TransferRepoTest : BaseUnitTest() { // MARK: - markSettled - @Test - fun `createPendingToSpendingActivity uses the funding wallet scope`() = test { - val order = previewBtOrder() - - val result = sut.createPendingToSpendingActivity( - order = order, - txId = "hardware-funding-tx", - fee = 1_000uL, - feeRate = 2uL, - walletId = "hardware-wallet", - ) - - assertTrue(result.isSuccess) - verify(activityService).createSentOnchainActivityFromSendResult( - txid = "hardware-funding-tx", - address = order.payment?.onchain?.address.orEmpty(), - amount = order.feeSat, - fee = 1_000uL, - feeRate = 2uL, - isTransfer = true, - channelId = order.channel?.shortChannelId, - walletId = "hardware-wallet", - ) - } - @Test fun `markSettled successfully marks transfer as settled`() = test { val settledAt = setupClockNowMock() @@ -1365,6 +943,26 @@ class TransferRepoTest : BaseUnitTest() { verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) } + @Test + fun `syncTransferStates settles TO_SPENDING transfer for a paid order that never got a channel`() = test { + val settledAt = setupClockNowMock() + val transfer = transferEntity(id = ID_TRANSFER, fundingTxId = null, channelId = null) + .copy(lspOrderId = ID_ORDER) + val order = btOrder(state2 = BtOrderState2.PAID, channel = null) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(mock())) + whenever(blocktankRepo.getOrder(ID_ORDER, refresh = false)).thenReturn(Result.success(order)) + whenever(blocktankRepo.fetchOrders(listOf(ID_ORDER))).thenReturn(Result.success(listOf(order))) + whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + } + @Test fun `syncTransferStates does not settle TO_SPENDING transfer when order stays unknown after fetch`() = test { setupClockNowMock() @@ -1976,8 +1574,6 @@ class TransferRepoTest : BaseUnitTest() { coreService = coreService, transferDao = transferDao, clock = clock, - cacheStore = cacheStore, - connectivityRepo = connectivityRepo, ) testSut.activeTransfers.test { diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index b663a8bdf9..342da8dd1d 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -63,40 +63,6 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { ) } - @Test - fun `should calculate LSP order transfer to spending using transfer amount`() = test { - val balance = newBalanceDetails() - val amountSats = 50_000uL - val transfers = listOf( - newTransferEntity( - type = TransferType.TO_SPENDING, - amountSats = amountSats.toLong(), - lspOrderId = "lsp-order-id", - channelId = null - ) - ) - - whenever(lightningRepo.getChannels()).thenReturn(emptyList()) - whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) - - val result = sut() - - assertTrue(result.isSuccess) - val balanceState = result.getOrThrow() - assertEquals(amountSats, balanceState.balanceInTransferToSpending) - assertEquals( - balance.totalOnchainBalanceSats, - balanceState.totalOnchainSats, - "Onchain balance unchanged - LDK already reflects sent payment to LSP" - ) - assertEquals( - balance.totalLightningBalanceSats, - balanceState.totalLightningSats, - "Lightning balance unchanged - channel not open yet" - ) - assertEquals(200_000uL, balanceState.totalSats) - } - @Test fun `should subtract estimated fee from channelFundableBalance`() = test { val fundableBalance = 75_000uL @@ -214,39 +180,6 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { assertEquals(150_000uL, balanceState.totalSats) } - @Test - fun `should subtract LSP funding transaction while onchain balance has not synced`() = test { - val initialOnchainSats = 86_901uL - val transferSats = 20_212uL - val txTotalSats = 25_809uL - val balance = newBalanceDetails().copy( - totalOnchainBalanceSats = initialOnchainSats, - totalLightningBalanceSats = 0uL, - ) - whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance)) - val transfers = listOf( - newTransferEntity( - type = TransferType.TO_SPENDING, - amountSats = transferSats.toLong(), - fundingTxId = "funding-tx-id", - lspOrderId = "lsp-order-id", - txTotalSats = txTotalSats.toLong(), - preTransferOnchainSats = initialOnchainSats.toLong(), - ) - ) - - whenever(lightningRepo.getChannels()).thenReturn(emptyList()) - whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) - - val result = sut() - - assertTrue(result.isSuccess) - val balanceState = result.getOrThrow() - assertEquals(61_092uL, balanceState.totalOnchainSats) - assertEquals(transferSats, balanceState.balanceInTransferToSpending) - assertEquals(81_304uL, balanceState.totalSats) - } - @Test fun `should move LSP order transfer to pending channel once LDK reports the balance`() = test { val channelId = "lsp-pending-channel-id" diff --git a/docs/balance.md b/docs/balance.md index fd917fb3bb..8568ad0c18 100644 --- a/docs/balance.md +++ b/docs/balance.md @@ -185,12 +185,6 @@ See: `ChannelDetails.valueToSelfSats` extension in `ChannelDetails.kt` ### Balance Adjustments -**toSpending transfers - Channel Orders (TO_SPENDING with lspOrderId):** -- Payment has been SENT to LSP (UTXO spent from wallet) -- LDK's `totalOnchainBalanceSats` already reflects the sent payment -- No adjustment needed - balance already correct -- **Result:** User sees correct balance reflecting sent payment - **toSpending transfers - Manual Channels (MANUAL_SETUP with channelId):** - Funding tx has been SENT (UTXO spent from wallet) - LDK's `totalOnchainBalanceSats` already reflects the spent UTXO @@ -220,15 +214,11 @@ See: `DeriveBalanceStateUseCase.kt` ## Edge Cases -**LSP Channel (TO_SPENDING):** -- Payment sent, waiting for LSP to open channel -- Channel appears in pending state +**Manual Channel (MANUAL_SETUP):** +- Channel appears in pending state with its channelId - Balance shows in total_lightning_balance_sats - We subtract it until isChannelReady -**Manual Channel (MANUAL_SETUP):** -- Same as LSP but we have channelId immediately - **Coop Close:** - Balance stays in lightning_balances as ClaimableAwaitingConfirmations - We subtract it until it disappears (swept) diff --git a/docs/transfer.md b/docs/transfer.md index 06671be7be..a058bf5dd8 100644 --- a/docs/transfer.md +++ b/docs/transfer.md @@ -4,7 +4,7 @@ **Transfer Directions:** - **Transfers to Spending** = Moving from savings (onchain) → spending (lightning) - - Channel orders: Pay for channel to LSP + - Channel orders: legacy Blocktank orders paid before Blocktank stopped selling channels - Manual channels: Fund channel to non-LSP node - **Transfers to Savings** = Moving from spending (lightning) → savings (onchain) - Channel closes: Funds swept from spending to savings @@ -36,7 +36,7 @@ When a transfer is settled: ## Transfer Types ### TransferType Enum -- `TO_SPENDING`: LSP channel purchase +- `TO_SPENDING`: legacy Blocktank channel order (no longer created) - `MANUAL_SETUP`: Manual channel open - `TO_SAVINGS`: Generic channel close - `COOP_CLOSE`: Cooperative channel close @@ -71,44 +71,12 @@ See: `TransferEntity.kt` ## Lifecycle Flows -### LSP Channel Purchase (TO_SPENDING) +### Legacy Blocktank Order (TO_SPENDING) -**Phase 1: Payment** -``` -User confirms amount - ↓ -Create Blocktank order - ↓ -Send onchain payment to LSP - ↓ -CREATE TRANSFER: - id = UUID - type = TO_SPENDING - amountSats = order.clientBalanceSat - channelId = null (not available yet) - fundingTxId = null - lspOrderId = order.id - isSettled = false -``` - -**Phase 2: Channel Appears** -``` -Poll order status OR receive ChannelPending event - ↓ -Channel appears in LDK with fundingTxo - ↓ -Just-in-time resolution via lspOrderId: - order.channel.fundingTx.id → match LDK channel.fundingTxo.txid -``` - -**Phase 3: Channel Ready** -``` -ChannelReady event OR isChannelReady becomes true - ↓ -SETTLE TRANSFER: - isSettled = true - settledAt = now -``` +Blocktank no longer sells channels, so the app creates no new `TO_SPENDING` record. A record from before the sunset +settles in `syncTransferStates()`: +- its channel becomes ready, or is closed (in LDK's closed channels or the order's channel state); +- its order is known and has no channel, since Blocktank will not open one any more. ### Manual Channel Open (MANUAL_SETUP) @@ -209,7 +177,6 @@ For manual transfers: See: `TransferRepo.resolveChannelIdForTransfer()` ### Creation Points -- TO_SPENDING: When payment sent (`TransferViewModel.onTransferToSpendingConfirm`) - MANUAL_SETUP: When ChannelPending event received (`ExternalNodeViewModel.onConfirm`) - COOP_CLOSE: When close initiated (`TransferViewModel.closeSelectedChannels`) - FORCE_CLOSE: When force close initiated (`TransferViewModel.forceTransfer`) @@ -219,7 +186,7 @@ See: `TransferRepo.resolveChannelIdForTransfer()` - Just-in-time channelId resolution via `resolveChannelIdForTransfer()` ### Settlement Conditions -- toSpending (TO_SPENDING/MANUAL_SETUP): When channel.isChannelReady == true +- toSpending (TO_SPENDING/MANUAL_SETUP): When channel.isChannelReady == true; a legacy order without a channel also settles - toSavings (TO_SAVINGS/COOP_CLOSE/FORCE_CLOSE): When balance.channelId not in lightning_balances ### Event-Driven Updates @@ -248,10 +215,6 @@ See: `ActivityRepo.kt` for metadata application logic Transfer states are used to adjust displayed balances to match UX requirements. **toSpending transfers (savings → spending):** -- **Channel orders** (`lspOrderId` exists): - - Payment SENT to LSP from onchain wallet - - LDK's `totalOnchainBalanceSats` already reflects sent payment - - No adjustment needed - balance already correct - **Manual channels** (`channelId` exists): - Funding tx SENT (UTXO spent from wallet) - LDK's `totalOnchainBalanceSats` already reflects spent UTXO @@ -274,11 +237,10 @@ See: `DeriveBalanceStateUseCase.kt` ## Edge Cases & Failure Handling -### LSP Order Expires +### Legacy Order Never Opens a Channel ``` -Detection: Order state == EXPIRED -Action: Mark transfer as settled (it failed, no longer affects balance) -Consider: Add failureReason field to track this +Detection: the order is known and has no channel (expired, or paid but never opened) +Action: Mark transfer as settled (no channel will come, so it no longer affects balance) ``` ### App Restarts During Transfer @@ -327,13 +289,6 @@ Transfers are serialized to JSON and included in backup. Restore deserializes an ## Testing Scenarios -### TO_SPENDING Happy Path -1. Create order, send payment -2. Verify transfer created with lspOrderId -3. Wait for channel to appear -4. Wait for ChannelReady -5. Verify transfer settled - ### MANUAL_SETUP Happy Path 1. Open channel 2. Verify transfer created on ChannelPending