diff --git a/.github/workflows/release-internal.yml b/.github/workflows/release-internal.yml index 8099065f11..1a042d86ca 100644 --- a/.github/workflows/release-internal.yml +++ b/.github/workflows/release-internal.yml @@ -73,6 +73,8 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.INTERNAL_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.INTERNAL_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.INTERNAL_KEY_PASSWORD }} + # Optional: without it the build passes and Spark (Lightning without channels) stays off. + BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} run: | set -euo pipefail ./gradlew assembleMainnetRelease --no-daemon --stacktrace diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edcca33abc..a957db809d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,15 @@ jobs: printf '%s' "$BITKIT_KEYSTORE_BASE64" | base64 --decode > "$keystore_path" echo "KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" + - name: Check Breez API key + env: + BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} + run: | + if [ -z "${BREEZ_API_KEY// /}" ]; then + echo "::error::BREEZ_API_KEY is missing from the release environment; a release without it ships with Spark (Lightning without channels) off." + exit 1 + fi + - name: Build release artifacts env: GPR_USER: ${{ secrets.GPR_USER || github.actor }} @@ -74,6 +83,7 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.BITKIT_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.BITKIT_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.BITKIT_KEY_PASSWORD }} + BREEZ_API_KEY: ${{ secrets.BREEZ_API_KEY }} run: | set -euo pipefail ./gradlew assembleMainnetRelease bundleMainnetRelease --no-daemon --stacktrace diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6198983ef9..04873d1fbc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -356,6 +356,8 @@ androidComponents { "USDT_RPC_URL", localProp("USDT_RPC_URL").orElse("https://bitkit.to/v1/usdt/chain-rpc").stringField(), ) + buildConfigFields.put("BREEZ_API_KEY", localProp("BREEZ_API_KEY").orElse("").stringField()) + buildConfigFields.put("ESPLORA_URL", localProp("ESPLORA_URL").orElse("").stringField()) buildConfigFields.put("E2E", e2eEnv.booleanField()) buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) @@ -566,6 +568,7 @@ dependencies { implementation(libs.bitkit.core) } implementation(libs.paykit) + implementation(libs.breez.sdk.spark) { exclude(group = "net.java.dev.jna", module = "jna") } implementation(libs.vss.client) if (localBitkitCoreAar == null) nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact()) diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 5a1c735fdc..e5085ff311 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -212,7 +212,9 @@ 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, diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index b00865ba75..b016c232e3 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -15,6 +15,7 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.msatCeilOf import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo +import to.bitkit.repositories.SparkRepo import to.bitkit.services.MigrationService import to.bitkit.utils.Logger import javax.inject.Inject @@ -35,6 +36,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( private val settingsStore: SettingsStore, private val clock: Clock, private val receivedNotificationContent: ReceivedNotificationContent, + private val sparkRepo: SparkRepo, ) { private val presentationClaimsLock = Any() private val presentationClaims = mutableSetOf() @@ -124,6 +126,11 @@ class NotifyPaymentReceivedHandler @Inject constructor( if (command.details.amountSats <= 0) return false } + if (sparkRepo.markWithdrawalAsTransfer(command.txid)) { + Logger.debug("Skipping onchain receive '${command.txid}' from a Spark withdrawal", context = TAG) + return false + } + if (settings.pendingRestoreActivitySeen) { Logger.debug("Skipping onchain receive '${command.txid}' until the first sync after restore", context = TAG) return false diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 83a1ed564f..09b2fd0dab 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -31,6 +31,14 @@ internal object Env { ).filter { it.first in enabled }.map { it.second } } + val breezApiKey: String? get() = BuildConfig.BREEZ_API_KEY.takeIf { it.isNotBlank() } + + /** + * Esplora endpoint for the node's chain source on regtest builds, set to put the wallet on Spark's regtest chain + * through `scripts/spark-regtest-esplora.py`; Electrum is used when unset. + */ + val regtestEsploraUrl: String? + get() = BuildConfig.ESPLORA_URL.takeIf { it.isNotBlank() && network == Network.REGTEST } val isDebug = BuildConfig.DEBUG const val isE2eTest = BuildConfig.E2E val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() @@ -226,6 +234,12 @@ internal object Env { fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") + fun sparkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "spark") + + /** A restored Spark exit data backup waiting to be imported once Spark connects; outside Spark's storage. */ + fun sparkExitRestorePath(walletIndex: Int) = + Path(storagePathOf(walletIndex, network.name.lowercase(), "spark-restore"), "exit-state.json").toString() + fun bitkitCoreStoragePath(walletIndex: Int): String { return storagePathOf(walletIndex, network.name.lowercase(), "core") } diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index d71ad17e1d..484bb3a05d 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -42,6 +42,10 @@ enum class BackupCategory( icon = R.drawable.ic_tag, title = R.string.settings__backup__category_tags, ), + SPARK_EXIT( + icon = R.drawable.ic_lightning, + title = R.string.settings__backup__category_spark_exit, + ), // Descoped in v1, will return in v2: // PROFILE( // icon = R.drawable.ic_user, diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 4a099eac1f..f3e05e639b 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -88,3 +88,15 @@ data class WidgetsBackupV1( val createdAt: Long, val widgets: WidgetsData, ) + +/** + * The Spark wallet's unilateral exit data, as the SDK exports it, kept so a wallet restored on another device can + * still exit on-chain while the Spark operators are unreachable. + */ +@Serializable +data class SparkExitBackupV1( + val version: Int = 1, + val createdAt: Long, + val network: String, + val exitState: String, +) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index c3dde00481..9d90f5a89f 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -19,8 +19,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -69,6 +71,7 @@ import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.PaykitAllowanceStateBackup import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.SettingsBackupV1 +import to.bitkit.models.SparkExitBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WalletScope @@ -137,6 +140,7 @@ class BackupRepo @Inject constructor( private val keychain: Keychain, private val onchainSendAttemptStore: OnchainSendAttemptStore, private val lightningRepo: Provider, + private val sparkRepo: SparkRepo, private val vssStoreIdProvider: VssStoreIdProvider, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, @@ -435,6 +439,20 @@ class BackupRepo @Inject constructor( // payment and sync traffic does not re-upload the whole metadata envelope. dataListenerJobs.add(observeBackupChanges(activityRepo.activityTagsChanged, BackupCategory.METADATA)) + // SPARK_EXIT - back up once Spark is ready, which replays when observing starts after it connected, + // and again whenever Spark announces its unilateral exit data changed + dataListenerJobs.add( + scope.launch { + merge( + sparkRepo.state.map { it.isReady }.distinctUntilChanged().filter { it }, + sparkRepo.exitStateChanged, + ).collect { + if (shouldSkipBackup()) return@collect + markBackupRequired(BackupCategory.SPARK_EXIT) + } + } + ) + // LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups @OptIn(FlowPreview::class) val lightningConnectionsJob = scope.launch { @@ -581,6 +599,7 @@ class BackupRepo @Inject constructor( val upload = when (category) { BackupCategory.WALLET -> uploadWalletBackup() BackupCategory.USDT_RECOVERY -> uploadUsdtRecoveryBackup() + BackupCategory.SPARK_EXIT -> uploadSparkExitBackup() else -> runSuspendCatching { vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)).getOrThrow() } @@ -619,6 +638,15 @@ class BackupRepo @Inject constructor( } } + /** Nothing to back up until Spark connects, which marks [BackupCategory.SPARK_EXIT] required again. */ + private suspend fun uploadSparkExitBackup(): Result = runSuspendCatching { + val backup = sparkRepo.exitBackup().getOrThrow() ?: return@runSuspendCatching + vssBackupClient.putObject( + key = BackupCategory.SPARK_EXIT.name, + data = json.encodeToString(backup).toByteArray(), + ).getOrThrow() + } + suspend fun persistWalletBackup(): Result = withContext(ioDispatcher) { walletBackupMutex.withLock { uploadWalletBackup() } @@ -752,6 +780,10 @@ class BackupRepo @Inject constructor( BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") BackupCategory.USDT_RECOVERY -> throw UsdtException.BackupUnavailable() + BackupCategory.SPARK_EXIT -> { + val backup = checkNotNull(sparkRepo.exitBackup().getOrThrow()) { "Spark is not connected" } + json.encodeToString(backup).toByteArray() + } } private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { @@ -955,6 +987,12 @@ class BackupRepo @Inject constructor( } // SETTINGS is optional; preserving explicit local corrections is required. connectionSession.replay() + // Before the slower categories, so the exit data is staged before Spark connects and syncs. + performRestore(BackupCategory.SPARK_EXIT) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + sparkRepo.restoreExitBackup(parsed, backupWalletIndex()).getOrThrow() + parsed.createdAt + } val retainedWalletBackup = keychain.loadString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) if (retainedWalletBackup.isNullOrEmpty()) { var didRestoreWalletBackup = false diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 46d9fc0edd..ad995b68cc 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -49,6 +50,7 @@ import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.CoinSelectionAlgorithm +import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.NodeStatus @@ -56,6 +58,7 @@ import org.lightningdevkit.ldknode.OutPoint import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentHash import org.lightningdevkit.ldknode.PaymentId +import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid @@ -69,6 +72,7 @@ import to.bitkit.data.keychain.requireWalletRestoreComplete import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching @@ -80,6 +84,7 @@ import to.bitkit.models.ElectrumServer import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult +import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.models.TransactionSpeed import to.bitkit.models.WalletScope import to.bitkit.models.safe @@ -130,6 +135,7 @@ class LightningRepo @Inject constructor( private val vssBackupClientLdk: VssBackupClientLdk, private val urlValidator: UrlValidator, private val electrumProbeService: ElectrumProbeService, + private val sparkRepo: SparkRepo, ) : OnchainReleasing { private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() @@ -138,6 +144,12 @@ class LightningRepo @Inject constructor( val nodeEventUpdates = _nodeEventUpdates.asSharedFlow() val nodeEvents = nodeEventUpdates.map { it.event } + /** Whether Spark is connected and can send and receive Lightning payments. */ + val isSparkReady: Flow get() = sparkRepo.state.map { it.isReady }.distinctUntilChanged() + + /** Whether Spark synced its payments since it connected, so its payment list is complete. */ + val isSparkSynced: Flow get() = sparkRepo.state.map { it.hasSynced }.distinctUntilChanged() + private val scope = appScope(bgDispatcher, TAG) private val _eventHandlers = ConcurrentHashMap.newKeySet() @@ -180,6 +192,7 @@ class LightningRepo @Inject constructor( init { observeConnectivityForSyncRetry() + scope.launch { sparkRepo.nodeEvents.collect { onSparkEvent(it) } } } private fun observeConnectivityForSyncRetry() { @@ -360,6 +373,9 @@ class LightningRepo @Inject constructor( if (shouldCancelPendingStop) cancelPendingStop() + // Spark needs no node, so it starts alongside it and stays up while the node stops in background. + scope.launch { sparkRepo.start(walletIndex) } + eventHandler?.let { _eventHandlers.add(it) } // Track retry state outside mutex to avoid deadlock (Mutex is non-reentrant) @@ -607,6 +623,14 @@ class LightningRepo @Inject constructor( ) } + private suspend fun onSparkEvent(event: Event) { + val settledReceiveInvoice = (event as? Event.PaymentReceived)?.let { invalidateSettledReceiveInvoice(it) } + _eventHandlers.toList().forEach { + runCatching { it.invoke(event) } + } + _nodeEventUpdates.emit(NodeEventUpdate(event = event, settledReceiveInvoice = settledReceiveInvoice)) + } + private suspend fun invalidateSettledReceiveInvoice(event: Event.PaymentReceived): SettledReceiveInvoice? { val cachedReceive = runSuspendCatching { cacheStore.data.first() @@ -907,6 +931,7 @@ class LightningRepo @Inject constructor( lastKnownNodeId = null _lastKnownChannels.update { persistentListOf() } lightningService.wipeStorage(walletIndex) + sparkRepo.wipe(walletIndex) clearProbeOutcomes() _lightningState.update { LightningState( @@ -1304,6 +1329,9 @@ class LightningRepo @Inject constructor( expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoice") { requireWalletRestoreComplete() + if (shouldReceiveWithSpark(amountSats)) { + return@executeWhenNodeRunning sparkRepo.createInvoice(amountSats, description, expirySeconds) + } runCatching { lightningService.receive(amountSats, description, expirySeconds) } } @@ -1313,9 +1341,34 @@ class LightningRepo @Inject constructor( expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoiceMsats") { requireWalletRestoreComplete() + if (shouldReceiveWithSpark(amountMsats / MSATS_PER_SAT)) { + val amountSats = (amountMsats / MSATS_PER_SAT).takeIf { it > 0uL } + return@executeWhenNodeRunning sparkRepo.createInvoice(amountSats, description, expirySeconds) + } runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } } + /** Receives on Spark when no ready channel has the inbound capacity for [amountSats]. */ + private fun shouldReceiveWithSpark(amountSats: ULong?): Boolean { + if (!sparkRepo.canReceive()) return false + val readyChannels = _lightningState.value.channels.filter { it.isChannelReady } + return !ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = readyChannels.isNotEmpty(), + inboundCapacitySats = readyChannels.calculateRemoteBalance(), + invoiceAmountSats = amountSats, + ) + } + + /** Pays with Spark when no channel has the outbound capacity for the invoice amount. */ + private fun shouldPayWithSpark(bolt11: String, sats: ULong?): Boolean { + val amountSats = sats ?: runCatching { Bolt11Invoice.fromStr(bolt11).amountMilliSatoshis() }.getOrNull() + ?.let { it / MSATS_PER_SAT } ?: return false + val state = _lightningState.value + val ldkCanSend = state.nodeLifecycleState.canRun() && + state.channels.totalNextOutboundHtlcLimitSats() >= amountSats + return !ldkCanSend && sparkRepo.canSend(amountSats) + } + suspend fun fetchLnurlInvoice( data: LnurlPayData, amountMsats: ULong, @@ -1409,6 +1462,22 @@ class LightningRepo @Inject constructor( paymentDeadlineAt: Instant?, maxTotalRoutingFeeMsat: ULong?, onBeforeSend: suspend () -> Boolean, + ): Result { + if (shouldPayWithSpark(bolt11, sats)) { + runCatching { requireWalletRestoreComplete() }.onFailure { return Result.failure(it) } + if (!onBeforeSend()) return Result.failure(PaymentAbortedBeforeSend()) + runCatching { requireWalletRestoreComplete() }.onFailure { return Result.failure(it) } + return sparkRepo.payInvoice(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat) + } + return payInvoiceWithLdk(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat, onBeforeSend) + } + + private suspend fun payInvoiceWithLdk( + bolt11: String, + sats: ULong?, + paymentDeadlineAt: Instant?, + maxTotalRoutingFeeMsat: ULong?, + onBeforeSend: suspend () -> Boolean, ): Result = executeWhenNodeRunning("payInvoice") { requireWalletRestoreComplete() waitForUsableChannels() @@ -1420,6 +1489,17 @@ class LightningRepo @Inject constructor( } } + /** Confirmations of an on-chain transaction this wallet sent or received, 0 while unconfirmed. */ + suspend fun onchainConfirmations(txid: String): UInt? = withContext(bgDispatcher) { + val kind = lightningService.listPayments() + ?.map { it.kind } + ?.filterIsInstance() + ?.firstOrNull { it.txid.equals(txid, ignoreCase = true) } ?: return@withContext null + val confirmed = kind.status as? ConfirmationStatus.Confirmed ?: return@withContext 0u + val tip = _lightningState.value.block()?.height ?: return@withContext null + if (tip < confirmed.height) 0u else tip - confirmed.height + 1u + } + suspend fun listPaymentsOrNull(): List? = withContext(bgDispatcher) { lightningService.listPayments() } @@ -1872,6 +1952,17 @@ class LightningRepo @Inject constructor( } } + /** Finishes an accepted transfer locally; [orderId] is null for a transfer that funds the Spark wallet. */ + suspend fun completeAcceptedTransferFollowup(orderId: String?, txid: String) { + val attempt = onchainSendAttemptStore.current() ?: return + if (attempt.isTransfer && 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 @@ -2206,6 +2297,7 @@ class LightningRepo @Inject constructor( fun canSend(amountSats: ULong): Boolean { val state = _lightningState.value + if (sparkRepo.canSend(amountSats)) return true if (!state.nodeLifecycleState.canRun()) return false return state.channels.totalNextOutboundHtlcLimitSats() >= amountSats } @@ -2256,9 +2348,22 @@ class LightningRepo @Inject constructor( if (state.nodeLifecycleState.isRunning()) state.channels else it } + /** The completion event of a Spark send that settled before its caller started watching for it. */ + suspend fun settledSparkSendEvent(paymentHash: String): Event? = sparkRepo.settledSendEvent(paymentHash) + + /** Spark's record of the Lightning send with [paymentHash], for recovery after its completion event was missed. */ + suspend fun sparkLightningSend(paymentHash: String): SparkLightningSend = sparkRepo.lightningSend(paymentHash) + + /** False while Spark is supported but its payment list is not complete yet, so a missing payment proves nothing. */ + fun canListSparkPayments(): Boolean = sparkRepo.canListPayments() + + /** + * Whether Lightning can be received, through a usable channel or Spark; Paykit publishes a Lightning endpoint + * only then. + */ fun canReceive(): Boolean { val isRunning = _lightningState.value.nodeLifecycleState.isRunning() - return isRunning && lightningService.canReceive() + return isRunning && (lightningService.canReceive() || sparkRepo.canReceive()) } suspend fun bumpFeeByRbf( @@ -2328,6 +2433,7 @@ class LightningRepo @Inject constructor( } suspend fun estimateRoutingFees(bolt11: String): Result = executeWhenNodeRunning("estimateRoutingFees") { + if (shouldPayWithSpark(bolt11, null)) return@executeWhenNodeRunning sparkRepo.estimateFeeSats(bolt11, null) Logger.info("Estimating routing fees for bolt11: $bolt11", context = TAG) lightningService.estimateRoutingFees(bolt11).onSuccess { Logger.info("Routing fees estimated: '$it'", context = TAG) @@ -2338,6 +2444,9 @@ class LightningRepo @Inject constructor( suspend fun estimateRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result = executeWhenNodeRunning("estimateRoutingFeesForAmount") { + if (shouldPayWithSpark(bolt11, amountSats)) { + return@executeWhenNodeRunning sparkRepo.estimateFeeSats(bolt11, amountSats) + } Logger.info("Estimating routing fees for amount: '$amountSats'", context = TAG) lightningService.estimateRoutingFeesForAmount(bolt11, amountSats).onSuccess { Logger.info("Routing fees estimated: '$it'", context = TAG) @@ -2639,6 +2748,7 @@ class LightningRepo @Inject constructor( private const val VSS_KEY_EXTERNAL_SCORES_CACHE = "external_pathfinding_scores_cache" private const val MS_SYNC_LOOP_DEBOUNCE = 500L private const val SYNC_RETRY_DELAY_MS = 15_000L + private const val MSATS_PER_SAT = 1_000uL private val BACKGROUND_STOP_DELAY = 5.seconds private val CHANNELS_USABLE_TIMEOUT = 15.seconds private val CHANNELS_USABLE_POLL_DELAY = 1.seconds diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 1c72fe4bae..de0910b5ab 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -42,6 +42,7 @@ import javax.inject.Singleton import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.Instant +import breez_sdk_spark.PaymentStatus as SparkPaymentStatus /** The side effects of paying one request, kept apart so the admission logic is testable without a node. */ @Singleton @@ -132,16 +133,35 @@ class PaykitAllowancePayer @Inject constructor( return PaymentOutcome.SUCCEEDED.takeIf { hasProof } } - /** True once the node can list its payments, so a payment missing from the list is one it never sent. */ + /** + * True once the node and Spark can both list their payments, so a payment missing from both lists is one the + * wallet never sent. + */ suspend fun canListLightningPayments(): Boolean = - runSuspendCatching { lightningRepo.listPaymentsOrNull() }.getOrNull() != null + runSuspendCatching { lightningRepo.listPaymentsOrNull() }.getOrNull() != null && + lightningRepo.canListSparkPayments() - /** The node's status for an outbound payment, or null when the node does not know it (or is not running). */ - suspend fun lightningPaymentStatus(paymentHash: String): PaymentStatus? = + /** + * The status of an outbound payment from the node, else from Spark; null when neither knows it (or the node is + * not running). PENDING while Spark cannot list its payments, so a Spark send is never read as never sent. + */ + suspend fun lightningPaymentStatus(paymentHash: String): PaymentStatus? { runSuspendCatching { lightningRepo.listPaymentsOrNull() } .getOrNull() ?.firstOrNull { it.direction == PaymentDirection.OUTBOUND && it.id.equals(paymentHash, ignoreCase = true) } - ?.status + ?.let { return it.status } + val spark = runSuspendCatching { lightningRepo.sparkLightningSend(paymentHash) } + .getOrDefault(SparkLightningSend.Unavailable) + return when (spark) { + SparkLightningSend.Unavailable -> PaymentStatus.PENDING + SparkLightningSend.NotFound -> null + is SparkLightningSend.Found -> when (spark.status) { + SparkPaymentStatus.COMPLETED -> PaymentStatus.SUCCEEDED + SparkPaymentStatus.PENDING -> PaymentStatus.PENDING + SparkPaymentStatus.FAILED -> PaymentStatus.FAILED + } + } + } } /** diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index be283f9cf6..50be0304fe 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -171,12 +171,15 @@ class PaykitAllowanceRepo @Inject constructor( .map { it.nodeLifecycleState.isRunning() } .distinctUntilChanged() .filter { it } - .collect { - // Attempts recovery left open while the node could not list payments are settled now. - executor.activeIdentity?.let { identity -> executor.recover(identity) } - executor.settleOpenLightningAttempts() - } + .collect { settleAttemptsLeftOpen() } } + // Attempts recovery left open while the node or Spark could not list payments are settled once both can. + scope.launch { lightningRepo.isSparkSynced.filter { it }.collect { settleAttemptsLeftOpen() } } + } + + private suspend fun settleAttemptsLeftOpen() { + executor.activeIdentity?.let { identity -> executor.recover(identity) } + executor.settleOpenLightningAttempts() } fun entry(id: String): PaykitAllowanceEntry? = _entries.value.firstOrNull { it.id == id } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index a07a0520e6..6036008521 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -45,6 +45,7 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.WalletScope import to.bitkit.services.PaykitSdkOperationLock import to.bitkit.services.PaykitSdkService +import to.bitkit.services.SparkError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import to.bitkit.utils.SignedTransactionId @@ -55,6 +56,7 @@ import javax.inject.Singleton import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +import breez_sdk_spark.PaymentStatus as SparkPaymentStatus /** New proof kinds must be readable on both platforms before either platform writes them to a wallet backup. */ @Serializable @@ -759,9 +761,10 @@ class PaykitPaymentProofRepo @Inject constructor( suspend fun failLightningPayment(paymentHash: String, submissionError: Throwable): Boolean { val error = generateSequence(submissionError) { it.cause } - .firstOrNull { it is ServiceError.PaymentDeadlineExpired } + .firstOrNull { it is ServiceError.PaymentDeadlineExpired || it is SparkError.SendRefused } ?: submissionError.asNodeException() ?: submissionError when (error) { + is SparkError.SendRefused, is NodeNotRunningError, is NodeRunTimeoutError, is ServiceError.NodeNotSetup, @@ -1294,25 +1297,47 @@ class PaykitPaymentProofRepo @Inject constructor( if (paymentHash == null) return val payment = payments.firstOrNull { it.direction == PaymentDirection.OUTBOUND && it.id.equals(paymentHash, ignoreCase = true) - } ?: return + } + if (payment == null) { + reconcileSparkLightningProof(proof, paymentHash) + return + } when (payment.status) { PaymentStatus.PENDING -> Unit - PaymentStatus.FAILED -> removeProofsLocked { - it.kind == PaykitPaymentProofKind.Lightning && - it.paymentIdentifier.equals(paymentHash, ignoreCase = true) - } - PaymentStatus.SUCCEEDED -> { - val preimage = (payment.kind as? PaymentKind.Bolt11)?.preimage - if (preimage != null && preimage.matchesPaymentHash(paymentHash)) { - val completed = proof.copy(proofData = preimage.lowercase()) - val proofs = loadProofs().toMutableList() - val index = proofs.indexOf(proof) - if (index >= 0) { - proofs[index] = completed - persistAndSubmit(listOf(completed), proofs) - } - } - } + PaymentStatus.FAILED -> removeLightningProofsLocked(paymentHash) + PaymentStatus.SUCCEEDED -> completeLightningProofLocked( + proof, + paymentHash, + (payment.kind as? PaymentKind.Bolt11)?.preimage, + ) + } + } + + private suspend fun reconcileSparkLightningProof(proof: PendingPaykitPaymentProof, paymentHash: String) { + val send = lightningRepo.sparkLightningSend(paymentHash) as? SparkLightningSend.Found ?: return + when (send.status) { + SparkPaymentStatus.PENDING -> Unit + SparkPaymentStatus.FAILED -> removeLightningProofsLocked(paymentHash) + SparkPaymentStatus.COMPLETED -> completeLightningProofLocked(proof, paymentHash, send.preimage) + } + } + + private suspend fun removeLightningProofsLocked(paymentHash: String) = removeProofsLocked { + it.kind == PaykitPaymentProofKind.Lightning && it.paymentIdentifier.equals(paymentHash, ignoreCase = true) + } + + private suspend fun completeLightningProofLocked( + proof: PendingPaykitPaymentProof, + paymentHash: String, + preimage: String?, + ) { + if (preimage == null || !preimage.matchesPaymentHash(paymentHash)) return + val completed = proof.copy(proofData = preimage.lowercase()) + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index >= 0) { + proofs[index] = completed + persistAndSubmit(listOf(completed), proofs) } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index f6c18ad460..3ab203f5bb 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -81,6 +81,7 @@ class PrivatePaykitRepo @Inject constructor( private val settingsStore: SettingsStore, private val addressReservationRepo: PrivatePaykitAddressReservationRepo, private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, private val walletRepo: WalletRepo, private val publicPaykitRepo: PublicPaykitRepo, private val paymentRequestRepo: PaykitPaymentRequestRepo, @@ -2428,7 +2429,7 @@ class PrivatePaykitRepo @Inject constructor( it.kind is PaymentKind.Bolt11 } .map { it.id.lowercase() } - .toSet() + .toSet() + sparkRepo.attemptedLightningPaymentHashes() private suspend fun isReceivedInvoiceSettled(paymentHash: String): Boolean = paymentHash in receivedSettledPaymentHashes() @@ -2444,7 +2445,7 @@ class PrivatePaykitRepo @Inject constructor( it.kind is PaymentKind.Bolt11 } .map { it.id } - .toSet() + .toSet() + sparkRepo.receivedLightningPaymentHashes() private suspend fun rememberReceivedInvoicePaymentHash(paymentHash: String, publicKey: String) { if (paymentHash.isBlank()) return diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt new file mode 100644 index 0000000000..398d92f4b5 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -0,0 +1,874 @@ +package to.bitkit.repositories + +import breez_sdk_spark.ClaimDeferredReason +import breez_sdk_spark.ClaimDepositOutcome +import breez_sdk_spark.FeePolicy +import breez_sdk_spark.FetchClaimDepositQuoteResponse +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentDetails +import breez_sdk_spark.PaymentMethod +import breez_sdk_spark.PaymentStatus +import breez_sdk_spark.PrepareSendPaymentResponse +import breez_sdk_spark.SdkEvent +import breez_sdk_spark.SdkException +import breez_sdk_spark.SendOnchainFeeQuote +import breez_sdk_spark.SendPaymentMethod +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.lightningdevkit.ldknode.PaymentId +import to.bitkit.async.appScope +import to.bitkit.data.CacheStore +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.SparkExitBackupV1 +import to.bitkit.models.WalletScope +import to.bitkit.models.safe +import to.bitkit.services.SparkError +import to.bitkit.services.SparkService +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import breez_sdk_spark.PaymentType as SparkPaymentType + +/** + * Lightning without channels, through the Breez SDK on Spark. Savings to Spending funds this wallet, + * receives fall back to it when no channel has the inbound capacity, and sends fall back to it when no + * channel has the outbound capacity. Its payments surface as ldk-node [Event]s through [nodeEvents], so the + * receive and send flows built on node events work for both rails. + */ +@Singleton +class SparkRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val sparkService: SparkService, + private val activityRepo: Provider, + private val cacheStore: CacheStore, + private val contactResolver: Provider, + private val clock: Clock, +) { + companion object { + private const val TAG = "SparkRepo" + private const val MSATS_PER_SAT = 1_000uL + + /** How often to look at a deposit until its Instant claim is decided. */ + private val CLAIM_POLL_INTERVAL = 5.seconds + + /** Looks for 5 minutes before telling the user Spark hasn't seen the deposit yet. */ + private const val CLAIM_POLL_ATTEMPTS = 60 + + private val SEND_STATUS_PRIORITY = listOf(PaymentStatus.COMPLETED, PaymentStatus.PENDING, PaymentStatus.FAILED) + + /** Seconds a withdrawal quote must still hold to be reused, so it can't expire mid-send. */ + private const val QUOTE_EXPIRY_MARGIN_SECS = 30uL + } + + private val scope = appScope(bgDispatcher, TAG) + + @Volatile + private var preparedWithdrawal: PreparedWithdrawal? = null + + /** Withdrawals this app sent, by txid, known before Spark lists them so their arrival reads as a transfer. */ + private val withdrawalTxids = ConcurrentHashMap.newKeySet() + + /** Deposits this app broadcast that Spark hasn't listed yet, by txid, so they show as pending at once. */ + private val broadcastDeposits = ConcurrentHashMap() + + /** Sends recorded before Spark knew their fee; its completion event reports 0 until a later sync. */ + private val sendsRecordedWithoutFee = ConcurrentHashMap.newKeySet() + + private var eventsJob: Job? = null + + /** Watchers of the claims of deposits this app sent, by txid; they outlive the screens that start them. */ + private val claimJobs = ConcurrentHashMap() + + private val _depositClaims = MutableStateFlow>(emptyMap()) + + /** Where the claim of each deposit sent from the transfer flow stands, by txid. */ + val depositClaims: StateFlow> = _depositClaims.asStateFlow() + + private val _state = MutableStateFlow(SparkState(isSupported = sparkService.isSupported)) + val state: StateFlow = _state.asStateFlow() + + private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) + val nodeEvents: SharedFlow = _nodeEvents.asSharedFlow() + + private val _exitStateChanged = MutableSharedFlow(extraBufferCapacity = 1) + + /** Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. */ + val exitStateChanged: SharedFlow = _exitStateChanged.asSharedFlow() + + suspend fun start(walletIndex: Int): Result = withContext(bgDispatcher) { + if (!sparkService.isSupported) return@withContext Result.success(Unit) + runSuspendCatching { + if (eventsJob == null) eventsJob = scope.launch { sparkService.events.collect { onSdkEvent(it) } } + sparkService.connect(walletIndex) + importRestoredExitState(walletIndex) + _exitStateChanged.emit(Unit) + refresh() + syncActivities() + }.onFailure { + Logger.error("Failed to start Spark wallet", it, context = TAG) + } + } + + suspend fun stop() = withContext(bgDispatcher) { + runSuspendCatching { sparkService.disconnect() } + .onFailure { Logger.warn("Failed to stop Spark wallet", it, context = TAG) } + _state.update { it.copy(isReady = false, hasSynced = false) } + } + + suspend fun wipe(walletIndex: Int) = withContext(bgDispatcher) { + runSuspendCatching { sparkService.wipeStorage(walletIndex) } + .onFailure { Logger.error("Failed to wipe Spark wallet", it, context = TAG) } + _state.update { SparkState(isSupported = sparkService.isSupported) } + } + + fun canReceive(): Boolean = _state.value.isReady + + fun canSend(amountSats: ULong): Boolean = _state.value.isReady && _state.value.sendableSats >= amountSats + + suspend fun createInvoice(amountSats: ULong?, description: String, expirySecs: UInt): Result = + withContext(bgDispatcher) { + runSuspendCatching { sparkService.receiveBolt11(amountSats, description, expirySecs) } + } + + /** Returns the Lightning routing fee plus Spark's 0.25% service fee for paying [bolt11]. */ + suspend fun estimateFeeSats(bolt11: String, amountSats: ULong?): Result = withContext(bgDispatcher) { + runSuspendCatching { + val method = sparkService.prepareSend(bolt11, amountSats).paymentMethod + (method as SendPaymentMethod.Bolt11Invoice).feeSats() + } + } + + private fun SendPaymentMethod.Bolt11Invoice.feeSats(): ULong = + sparkTransferFeeSats?.takeIf { sparkService.prefersSpark } ?: lightningFeeSats + + /** + * Pays [bolt11] and returns its payment hash, the id ldk-node payments use, so the send flow can match it. + * Fails with [SparkError.SendRefused] when nothing left the wallet: Spark was not connected, could not prepare the + * payment, or its fee was over [maxTotalFeeMsat]; and with [ServiceError.PaymentDeadlineExpired] past + * [paymentDeadlineAt]. A failure inside the send itself stays uncertain, as the payment may have been dispatched. + */ + suspend fun payInvoice( + bolt11: String, + amountSats: ULong?, + paymentDeadlineAt: Instant? = null, + maxTotalFeeMsat: ULong? = null, + ): Result = withContext(bgDispatcher) { + runSuspendCatching { + ensurePaymentDeadline(paymentDeadlineAt) + if (!sparkService.isConnected) throw SparkError.SendRefused("Spark is not connected") + val prepared = runSuspendCatching { sparkService.prepareSend(bolt11, amountSats) } + .getOrElse { throw SparkError.SendRefused("Spark could not prepare the payment", it) } + val method = prepared.paymentMethod as? SendPaymentMethod.Bolt11Invoice + ?: throw SparkError.SendRefused("Unexpected Spark payment method '${prepared.paymentMethod}'") + val feeSats = method.feeSats() + if (maxTotalFeeMsat != null && feeSats.safe() * MSATS_PER_SAT.safe() > maxTotalFeeMsat) { + throw SparkError.SendRefused("Spark fee '$feeSats' sats is over the limit '$maxTotalFeeMsat' msat") + } + ensurePaymentDeadline(paymentDeadlineAt) + val payment = sparkService.send(prepared) + Logger.info("Sent Spark payment '${payment.id}' with status '${payment.status}'", context = TAG) + method.invoiceDetails.paymentHash + }.onFailure { + Logger.error("Failed to pay invoice with Spark", it, context = TAG) + } + } + + private fun ensurePaymentDeadline(paymentDeadlineAt: Instant?) { + if (paymentDeadlineAt != null && clock.now() > paymentDeadlineAt) throw ServiceError.PaymentDeadlineExpired() + } + + /** The most an Instant claim of a deposit of [amountSats] may cost: the ceiling the user accepts. */ + suspend fun estimateDepositClaimFeeSats(amountSats: ULong): Result = withContext(bgDispatcher) { + runSuspendCatching { SparkService.maxDepositClaimFeeSats(sparkService.fastestFeeRate(), amountSats) } + } + + /** What Spark keeps to claim a deposit at 3 confirmations, at the current half-hour fee rate. */ + suspend fun estimateMatureClaimFeeSats(): Result = withContext(bgDispatcher) { + runSuspendCatching { SparkService.matureDepositClaimFeeSats(sparkService.halfHourFeeRate()) } + } + + /** + * Follows the claim of the deposit in [txid] that the user sent at [speed]. Instant claims it early as soon as + * Spark offers that within [ceilingSats], and asks the user when it costs more; Later leaves it to the claim at + * maturity. Runs in the app scope, so leaving the transfer screens does not drop an Instant claim. + */ + fun followDepositClaim(txid: String, speed: SparkClaimSpeed, ceilingSats: ULong) { + claimJobs.remove(txid)?.cancel() + when (speed) { + SparkClaimSpeed.Later -> setDepositClaim(txid, SparkDepositClaim.Later) + SparkClaimSpeed.Instant -> { + setDepositClaim(txid, SparkDepositClaim.Looking) + claimJobs[txid] = scope.launch { claimInstantly(txid, ceilingSats) } + } + } + } + + /** Accepts the repriced Instant claim of [txid], at the fee the provider asks now. */ + fun payRepricedClaim(txid: String) { + val repriced = _depositClaims.value[txid] as? SparkDepositClaim.Repriced ?: return + followDepositClaim(txid, SparkClaimSpeed.Instant, repriced.offer.earlyFeeSats) + } + + /** Leaves the deposit in [txid] to the claim at 3 confirmations. */ + fun waitForMatureClaim(txid: String) { + claimJobs.remove(txid)?.cancel() + setDepositClaim(txid, SparkDepositClaim.Later) + } + + private suspend fun claimInstantly(txid: String, ceilingSats: ULong) { + var isSeen = false + repeat(CLAIM_POLL_ATTEMPTS) { + runSuspendCatching { refresh() } + .onFailure { Logger.warn("Failed to refresh Spark state", it, context = TAG) } + if (isClaimed(txid)) { + setDepositClaim(txid, SparkDepositClaim.Claimed) + return + } + if (_depositClaims.value[txid] != SparkDepositClaim.Adding) { + val offer = depositClaimOffer(txid) + .onFailure { Logger.warn("Failed to quote Spark deposit '$txid'", it, context = TAG) } + .getOrNull() + if (offer != null) isSeen = true + val claim = when (val step = SparkClaimStep.of(offer, ceilingSats)) { + SparkClaimStep.KeepLooking -> null + SparkClaimStep.Adding -> SparkDepositClaim.Adding + SparkClaimStep.Unavailable -> SparkDepositClaim.Unavailable + is SparkClaimStep.Ask -> SparkDepositClaim.Repriced(step.offer) + is SparkClaimStep.ClaimEarly -> claimEarly(step.offer).getOrNull()?.toDepositClaim(step.offer) + } + if (claim != null) setDepositClaim(txid, claim) + if (claim?.isFinal == true) return + } + delay(CLAIM_POLL_INTERVAL) + } + if (_depositClaims.value[txid] == SparkDepositClaim.Adding) return + Logger.warn("Stopped looking for the Instant claim of Spark deposit '$txid'", context = TAG) + setDepositClaim(txid, if (isSeen) SparkDepositClaim.Unavailable else SparkDepositClaim.NotSeen) + } + + private fun isClaimed(txid: String): Boolean = + _state.value.deposits.any { it.isClaimed && it.txid.equals(txid, ignoreCase = true) } + + private fun setDepositClaim(txid: String, claim: SparkDepositClaim) { + Logger.debug("Set claim of Spark deposit '$txid' to '$claim'", context = TAG) + _depositClaims.update { it + (txid to claim) } + } + + /** How the deposit in [txid] can be claimed, or null while Spark has not seen it yet. */ + suspend fun depositClaimOffer(txid: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + val deposit = sparkService.listUnclaimedDeposits().firstOrNull { it.txid.equals(txid, ignoreCase = true) } + ?: return@runSuspendCatching null + SparkClaimOffer.from(deposit.txid, deposit.vout, sparkService.fetchClaimQuote(deposit.txid, deposit.vout)) + } + } + + /** Accepts the early claim of [offer] at its quoted fee. */ + suspend fun claimEarly(offer: SparkClaimOffer.Choice): Result = withContext(bgDispatcher) { + runSuspendCatching { + try { + SparkEarlyClaim.from(sparkService.claimDeposit(offer.txid, offer.vout, offer.earlyFeeSats)) + } catch (_: SdkException.DepositClaimInProgress) { + SparkEarlyClaim.Claimed + } + }.onSuccess { + Logger.info("Early claim of Spark deposit '${offer.txid}': '$it'", context = TAG) + refresh() + }.onFailure { + Logger.error("Failed to claim Spark deposit '${offer.txid}' early", it, context = TAG) + } + } + + suspend fun depositAddress(): Result = withContext(bgDispatcher) { + runSuspendCatching { sparkService.depositAddress() } + } + + /** Moves the whole Spark balance to the on-chain [address], fees included, for Spending to Savings. */ + suspend fun withdrawAll(address: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + val payment = sparkService.send(prepareWithdrawAll(address)) + preparedWithdrawal = null + (payment.details as? PaymentDetails.Withdraw)?.txId?.let { withdrawalTxids += it } + refresh() + }.onFailure { + Logger.error("Failed to withdraw from Spark", it, context = TAG) + } + } + + /** The fee [withdrawAll] pays at its speed, which comes out of the balance it moves. */ + suspend fun estimateWithdrawAllFeeSats(address: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + val speed = prepareWithdrawAll(address).withdrawalQuote().speedMedium + speed.userFeeSat.safe() + speed.l1BroadcastFeeSat.safe() + } + } + + /** + * Reuses the provider's withdrawal quote while it holds for the same address and balance, so the fee the confirm + * screen shows is the one the withdrawal pays; asking for a second quote while one held took minutes on regtest. + */ + private suspend fun prepareWithdrawAll(address: String): PrepareSendPaymentResponse { + val balanceSats = _state.value.balanceSats + val nowSecs = clock.now().epochSeconds.toULong() + preparedWithdrawal + ?.takeIf { it.address == address && it.balanceSats == balanceSats } + ?.takeIf { it.response.withdrawalQuote().expiresAt > nowSecs.safe() + QUOTE_EXPIRY_MARGIN_SECS.safe() } + ?.let { return it.response } + return sparkService.prepareSend(address, balanceSats, FeePolicy.FEES_INCLUDED) + .also { preparedWithdrawal = PreparedWithdrawal(address, balanceSats, it) } + } + + private fun PrepareSendPaymentResponse.withdrawalQuote(): SendOnchainFeeQuote = + (paymentMethod as SendPaymentMethod.BitcoinAddress).feeQuote + + /** + * Lists a deposit the app just broadcast as pending right away, then syncs so Spark sees it sooner than its + * minute-long background sync; Spark's own deposit state replaces it once listed. + */ + fun onDepositBroadcast(txid: String, amountSats: ULong) { + broadcastDeposits[txid] = amountSats + _state.update { it.withBroadcastDeposits() } + scope.launch { + runSuspendCatching { + sparkService.syncWallet() + refresh() + }.onFailure { Logger.warn("Failed to sync Spark after deposit '$txid'", it, context = TAG) } + } + } + + private fun SparkState.withBroadcastDeposits(): SparkState { + val known = deposits.map { it.txid }.toSet() + val local = broadcastDeposits.filterKeys { it !in known } + .map { (txid, amount) -> SparkDeposit(txid = txid, amountSats = amount, isClaimed = false) } + if (local.isEmpty()) return this + val all = local + deposits + return copy( + deposits = all.toImmutableList(), + pendingDepositSats = all.filterNot { it.isClaimed }.sumOf { it.amountSats }, + ) + } + + /** The exit data backup, or null while Spark is not connected and there is nothing to back up. */ + suspend fun exitBackup(): Result = withContext(bgDispatcher) { + runSuspendCatching { + val network = sparkService.networkName + if (!sparkService.isConnected || network == null) return@runSuspendCatching null + SparkExitBackupV1(createdAt = nowMillis(), network = network, exitState = sparkService.exportExitState()) + } + } + + /** + * Puts a restored exit data backup back: at once while Spark is connected, otherwise right after it connects, + * before it is used, so a restored wallet can exit on-chain even while the Spark operators are unreachable. + */ + suspend fun restoreExitBackup(backup: SparkExitBackupV1, walletIndex: Int): Result = + withContext(bgDispatcher) { + runSuspendCatching { + if (backup.network != sparkService.networkName) { + Logger.warn("Skipped Spark exit data from network '${backup.network}'", context = TAG) + return@runSuspendCatching + } + File(Env.sparkExitRestorePath(walletIndex)).writeText(backup.exitState) + if (sparkService.isConnected) importRestoredExitState(walletIndex) + } + } + + private suspend fun importRestoredExitState(walletIndex: Int) { + val file = File(Env.sparkExitRestorePath(walletIndex)) + if (!file.exists()) return + runSuspendCatching { sparkService.importExitState(file.readText()) } + .onSuccess { + file.delete() + Logger.info( + "Imported Spark exit data: '${it.importedLeaves}' leaves, '${it.skippedForeignLeaves}' foreign, " + + "'${it.skippedConflictingLeaves}' conflicting, '${it.skippedChains}' already held", + context = TAG, + ) + } + .onFailure { Logger.error("Failed to import Spark exit data", it, context = TAG) } + } + + suspend fun refreshState(): Result = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext Result.success(Unit) + runSuspendCatching { refresh() }.onFailure { Logger.warn("Failed to refresh Spark state", it, context = TAG) } + } + + private suspend fun refresh() { + val info = sparkService.getInfo() + val pending = sparkService.listUnclaimedDeposits().map { + SparkDeposit(txid = it.txid, amountSats = it.amountSats, isClaimed = false) + } + val payments = sparkService.listPayments() + val claimed = payments + .filter { it.method == PaymentMethod.DEPOSIT && it.status == PaymentStatus.COMPLETED } + .mapNotNull { payment -> + val deposit = payment.details as? PaymentDetails.Deposit ?: return@mapNotNull null + SparkDeposit( + txid = deposit.txId, + amountSats = payment.amount.toLong().toULong(), + isClaimed = true, + feeSats = payment.fees.toLong().toULong(), + claimedAt = payment.timestamp, + ) + } + (pending + claimed).forEach { broadcastDeposits.remove(it.txid) } + _state.update { + it.copy( + isReady = true, + balanceSats = info.balanceSats, + pendingDepositSats = pending.sumOf { deposit -> deposit.amountSats }, + deposits = (pending + claimed).toImmutableList(), + withdrawals = payments.toWithdrawals(), + recoverableFundsSats = info.recoverableFundsSats, + ).withBroadcastDeposits() + } + payments.filter { it.id in sendsRecordedWithoutFee && it.fees.toLong() > 0 }.forEach { recordActivity(it) } + markTransfers(payments) + } + + private fun List.toWithdrawals() = filter { it.status != PaymentStatus.FAILED } + .mapNotNull { payment -> + val withdraw = payment.details as? PaymentDetails.Withdraw ?: return@mapNotNull null + SparkWithdrawal( + txid = withdraw.txId, + amountSats = payment.amount.toLong().toULong(), + feeSats = payment.fees.toLong().toULong(), + ) + } + .toImmutableList() + + /** + * Marks the on-chain receive [txid] as a transfer from Spending when it is a Spark withdrawal, so it shows no + * Received sheet and reads as a transfer in Activity. Returns whether it is one. + */ + suspend fun markWithdrawalAsTransfer(txid: String): Boolean = withContext(bgDispatcher) { + val isWithdrawal = txid in withdrawalTxids || _state.value.withdrawals.any { it.txid == txid } + if (!isWithdrawal) return@withContext false + val activities = activityRepo.get() + activities.getOnchainActivityByTxId(txid)?.takeUnless { it.isTransfer }?.let { + activities.updateActivity(it.id, Activity.Onchain(it.copy(isTransfer = true))) + } + true + } + + private suspend fun markTransfers(payments: List) { + val activities = activityRepo.get() + payments.filter { it.status != PaymentStatus.FAILED } + .mapNotNull { (it.details as? PaymentDetails.Withdraw)?.txId } + .forEach { txid -> + val activity = activities.getOnchainActivityByTxId(txid)?.takeUnless { it.isTransfer } ?: return@forEach + activities.updateActivity(activity.id, Activity.Onchain(activity.copy(isTransfer = true))) + } + } + + /** + * The completion event of a Spark Lightning send that has already settled. A Spark-to-Spark payment settles inside + * the send call, so its event can fire before the caller starts watching for it. + */ + suspend fun settledSendEvent(paymentHash: String): Event? = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext null + runSuspendCatching { sparkService.listPayments() } + .onFailure { Logger.warn("Failed to list Spark payments", it, context = TAG) } + .getOrNull() + ?.firstOrNull { + val hash = (it.details as? PaymentDetails.Lightning)?.htlcDetails?.paymentHash + it.paymentType == SparkPaymentType.SEND && hash.equals(paymentHash, ignoreCase = true) + } + ?.toNodeEvent() + } + + /** + * False while Spark is supported but not connected or not yet synced, since its payment list is read from local + * storage that a restored wallet fills only on its first sync, so a missing payment proves nothing until then. + */ + fun canListPayments(): Boolean = + !sparkService.isSupported || (sparkService.isConnected && _state.value.hasSynced) + + /** + * Spark's record of the Lightning send with [paymentHash], for recovery after its completion event was missed. + * [SparkLightningSend.Unavailable] while Spark is supported but cannot list its payments, so a send it may hold + * is never read as one it never made. + */ + suspend fun lightningSend(paymentHash: String): SparkLightningSend = withContext(bgDispatcher) { + if (!sparkService.isSupported) return@withContext SparkLightningSend.NotFound + if (!canListPayments()) return@withContext SparkLightningSend.Unavailable + val payments = runSuspendCatching { sparkService.listPayments() } + .onFailure { Logger.warn("Failed to list Spark payments", it, context = TAG) } + .getOrElse { return@withContext SparkLightningSend.Unavailable } + payments + .filter { + val hash = (it.details as? PaymentDetails.Lightning)?.htlcDetails?.paymentHash + it.paymentType == SparkPaymentType.SEND && hash.equals(paymentHash, ignoreCase = true) + } + // A retry of a failed send shares its hash, so the outcome that settles it wins. + .minByOrNull { SEND_STATUS_PRIORITY.indexOf(it.status) } + ?.let { SparkLightningSend.Found(it.status, (it.details as PaymentDetails.Lightning).htlcDetails.preimage) } + ?: SparkLightningSend.NotFound + } + + /** Payment hashes of the Lightning payments Spark received. */ + suspend fun receivedLightningPaymentHashes(): Set = + lightningPaymentHashes { it.paymentType == SparkPaymentType.RECEIVE && it.status == PaymentStatus.COMPLETED } + + /** Payment hashes of the Lightning payments Spark sent or is sending. */ + suspend fun attemptedLightningPaymentHashes(): Set = + lightningPaymentHashes { it.paymentType == SparkPaymentType.SEND && it.status != PaymentStatus.FAILED } + + private suspend fun lightningPaymentHashes(filter: (Payment) -> Boolean): Set = withContext(bgDispatcher) { + if (!sparkService.isConnected) return@withContext emptySet() + runSuspendCatching { sparkService.listPayments() } + .onFailure { Logger.warn("Failed to list Spark payments", it, context = TAG) } + .getOrDefault(emptyList()) + .filter(filter) + .mapNotNull { (it.details as? PaymentDetails.Lightning)?.htlcDetails?.paymentHash?.lowercase() } + .toSet() + } + + private suspend fun syncActivities() { + sparkService.listPayments().forEach { recordActivity(it) } + } + + private suspend fun onSdkEvent(event: SdkEvent) { + runSuspendCatching { + when (event) { + is SdkEvent.Synced -> { + _state.update { it.copy(hasSynced = true) } + refresh() + } + is SdkEvent.PaymentSucceeded -> onPayment(event.payment) + is SdkEvent.PaymentFailed -> onPayment(event.payment) + is SdkEvent.PaymentPending -> recordActivity(event.payment) + is SdkEvent.UnclaimedDeposits -> Logger.warn( + "Found '${event.unclaimedDeposits.size}' unclaimed Spark deposits", + context = TAG, + ) + is SdkEvent.NewDeposits -> refresh() + is SdkEvent.UnilateralExitStateChanged -> _exitStateChanged.emit(Unit) + is SdkEvent.ClaimedDeposits -> refresh() + else -> Unit + } + }.onFailure { + Logger.error("Failed to handle Spark event '$event'", it, context = TAG) + } + } + + private suspend fun onPayment(payment: Payment) { + refresh() + recordActivity(payment) + payment.toNodeEvent()?.let { _nodeEvents.emit(it) } + } + + private suspend fun recordActivity(payment: Payment) { + val lightning = payment.details as? PaymentDetails.Lightning ?: return + if (payment.status == PaymentStatus.PENDING && payment.paymentType == SparkPaymentType.RECEIVE) { + return + } + val id = lightning.htlcDetails.paymentHash + val existing = (activityRepo.get().getActivity(id).getOrNull() as? Activity.Lightning)?.v1 + val activity = LightningActivity.create( + id = id, + txType = when (payment.paymentType) { + SparkPaymentType.SEND -> PaymentType.SENT + SparkPaymentType.RECEIVE -> PaymentType.RECEIVED + }, + status = when (payment.status) { + PaymentStatus.COMPLETED -> PaymentState.SUCCEEDED + PaymentStatus.PENDING -> PaymentState.PENDING + PaymentStatus.FAILED -> PaymentState.FAILED + }, + value = payment.amount.toLong().toULong(), + invoice = lightning.invoice, + timestamp = payment.timestamp, + fee = payment.fees.toLong().toULong(), + message = lightning.description.orEmpty(), + preimage = lightning.htlcDetails.preimage, + contact = existing?.contact ?: receivedContact(payment, id), + seenAt = existing?.seenAt, + ) + activityRepo.get().upsertActivity(Activity.Lightning(activity)) + if (payment.paymentType == SparkPaymentType.SEND && payment.fees.toLong() == 0L) { + sendsRecordedWithoutFee.add(payment.id) + } else { + sendsRecordedWithoutFee.remove(payment.id) + } + } + + private suspend fun receivedContact(payment: Payment, paymentHash: String): String? { + if (payment.paymentType != SparkPaymentType.RECEIVE || payment.status == PaymentStatus.FAILED) return null + if (cacheStore.data.first().isContactDetached(paymentHash, WalletScope.default)) return null + return runSuspendCatching { contactResolver.get().contactPublicKeyForPrivateInvoicePaymentHash(paymentHash) } + .onFailure { Logger.warn("Failed to resolve contact of Spark receive '$paymentHash'", it, context = TAG) } + .getOrNull() + } + + private fun Payment.toNodeEvent(): Event? { + val lightning = details as? PaymentDetails.Lightning ?: return null + val hash = lightning.htlcDetails.paymentHash + val amountMsat = amount.toLong().toULong() * MSATS_PER_SAT + return when (paymentType to status) { + SparkPaymentType.RECEIVE to PaymentStatus.COMPLETED -> + Event.PaymentReceived(hash, hash, amountMsat, emptyList()) + SparkPaymentType.SEND to PaymentStatus.COMPLETED -> + Event.PaymentSuccessful( + hash, + hash, + lightning.htlcDetails.preimage, + fees.toLong().toULong() * MSATS_PER_SAT + ) + SparkPaymentType.SEND to PaymentStatus.FAILED -> + Event.PaymentFailed(hash, hash, PaymentFailureReason.UNEXPECTED_ERROR) + else -> null + } + } +} + +private class PreparedWithdrawal( + val address: String, + val balanceSats: ULong, + val response: PrepareSendPaymentResponse, +) + +data class SparkState( + val isSupported: Boolean = false, + val isReady: Boolean = false, + /** Spark synced its payments at least once since it connected, so its payment list is complete. */ + val hasSynced: Boolean = false, + val balanceSats: ULong = 0uL, + /** Deposits seen on-chain that Spark claims into [balanceSats] after 3 confirmations. */ + val pendingDepositSats: ULong = 0uL, + val deposits: ImmutableList = persistentListOf(), + val withdrawals: ImmutableList = persistentListOf(), + val recoverableFundsSats: ULong = 0uL, +) { + /** The most Spark can send once its service fee is paid, so a payment near the full balance isn't offered. */ + val sendableSats: ULong get() = SparkFees.sendableSats(balanceSats) +} + +object SparkFees { + /** Spark keeps 0.25% of a Lightning send as its service fee, on top of routing. */ + const val SERVICE_FEE_PPM = 2_500uL + + private const val PPM = 1_000_000uL + + /** The largest amount whose fee, rounded up, fits beside it: `amount + ceil(amount * ppm / 1e6) <= balance`. */ + fun sendableSats(balanceSats: ULong): ULong { + val sendable = balanceSats.safe() * PPM.safe() / (PPM + SERVICE_FEE_PPM) + return if (sendable == 0uL || sendable.safe() + serviceFeeSats(sendable).safe() <= balanceSats) { + sendable + } else { + sendable - 1uL + } + } + + fun serviceFeeSats(amountSats: ULong): ULong = (amountSats.safe() * SERVICE_FEE_PPM.safe() + (PPM - 1uL)) / PPM +} + +/** Spark's record of a Lightning send. */ +sealed interface SparkLightningSend { + /** Spark is supported but cannot list its payments yet. */ + data object Unavailable : SparkLightningSend + + data object NotFound : SparkLightningSend + + data class Found(val status: PaymentStatus, val preimage: String?) : SparkLightningSend +} + +/** A Transfer to Savings out of Spark, with what Spark kept for it. */ +data class SparkWithdrawal( + val txid: String, + val amountSats: ULong, + val feeSats: ULong, +) + +/** An on-chain deposit into Spark: pending until Spark claims it after 3 confirmations. */ +data class SparkDeposit( + val txid: String, + val amountSats: ULong, + val isClaimed: Boolean, + val feeSats: ULong = 0uL, + val claimedAt: ULong? = null, +) + +/** + * How a deposit into Spark can be claimed. The provider decides per deposit whether to credit it early, at 0 + * confirmations or 1-2, for a fee above the standard claim's at 3 confirmations. + */ +sealed interface SparkClaimOffer { + val txid: String + val vout: UInt + + /** No early claim is offered, so Spark claims it at [confirmations] for [feeSats]. */ + data class Standard( + override val txid: String, + override val vout: UInt, + val feeSats: ULong, + val confirmations: UInt, + ) : SparkClaimOffer + + /** The early claim fits this wallet's fee cap, so Spark claims it at [confirmations] by itself. */ + data class Automatic( + override val txid: String, + override val vout: UInt, + val feeSats: ULong, + val confirmations: UInt, + ) : SparkClaimOffer + + /** The early claim costs more than the cap, so it waits for the user to accept [earlyFeeSats]. */ + data class Choice( + override val txid: String, + override val vout: UInt, + val earlyFeeSats: ULong, + val earlyConfirmations: UInt, + val standardFeeSats: ULong, + val standardConfirmations: UInt, + ) : SparkClaimOffer + + companion object { + fun from(txid: String, vout: UInt, quote: FetchClaimDepositQuoteResponse): SparkClaimOffer { + val mature = quote.mature + val early = quote.instant + ?: return Standard(txid, vout, mature.feeSats, mature.confirmationsRequired) + val cap = early.maxFeeSats + if (cap != null && early.feeSats <= cap) { + return Automatic(txid, vout, early.feeSats, early.confirmationsRequired) + } + return Choice( + txid = txid, + vout = vout, + earlyFeeSats = early.feeSats, + earlyConfirmations = early.confirmationsRequired, + standardFeeSats = mature.feeSats, + standardConfirmations = mature.confirmationsRequired, + ) + } + } +} + +/** What accepting an early claim did. */ +sealed interface SparkEarlyClaim { + /** The claim is submitted and the deposit reaches the balance shortly. */ + data object Claimed : SparkEarlyClaim + + /** Not offered at this depth yet; Spark claims it early once a confirmation brings the offer. */ + data object Waiting : SparkEarlyClaim + + /** The provider now asks [requiredFeeSats], more than was accepted. */ + data class Repriced(val requiredFeeSats: ULong) : SparkEarlyClaim + + /** The provider declined it, so Spark claims the deposit at 3 confirmations. */ + data object Declined : SparkEarlyClaim + + companion object { + fun from(outcome: ClaimDepositOutcome): SparkEarlyClaim = when (outcome) { + is ClaimDepositOutcome.Settled, ClaimDepositOutcome.Submitted -> Claimed + is ClaimDepositOutcome.Deferred -> when (val reason = outcome.reason) { + ClaimDeferredReason.NoEarlyClaimAvailable -> Waiting + is ClaimDeferredReason.MaxFeeExceeded -> Repriced(reason.requiredFeeSats) + is ClaimDeferredReason.ProviderDeclined -> Declined + } + } + } +} + +/** How fast the user wants a deposit in the spending balance. */ +enum class SparkClaimSpeed { + /** Pays the provider to add it before confirmations, up to the ceiling the user accepted. */ + Instant, + + /** Waits for 3 confirmations, at the lower fee of the claim at maturity. */ + Later, +} + +/** Where the claim of a deposit the user sent stands. */ +sealed interface SparkDepositClaim { + /** Spark has not listed the deposit yet, or offers no early claim at this depth. */ + data object Looking : SparkDepositClaim + + /** The early claim is under way and the deposit reaches the balance shortly. */ + data object Adding : SparkDepositClaim + + /** The deposit is in the spending balance. */ + data object Claimed : SparkDepositClaim + + /** The early claim now costs more than the user accepted; it waits for the user to pay it or wait. */ + data class Repriced(val offer: SparkClaimOffer.Choice) : SparkDepositClaim + + /** No early claim for this deposit, so Spark adds it after 3 confirmations. */ + data object Unavailable : SparkDepositClaim + + /** The user chose to wait for 3 confirmations. */ + data object Later : SparkDepositClaim + + /** Spark did not list the deposit in time; it still adds it after 3 confirmations. */ + data object NotSeen : SparkDepositClaim + + /** Whether nothing more is asked of the provider for this deposit. */ + val isFinal: Boolean get() = this !is Looking && this !is Adding +} + +/** What an Instant transfer does with the claim [SparkClaimOffer] Spark has for its deposit. */ +sealed interface SparkClaimStep { + /** Not listed yet: look again. */ + data object KeepLooking : SparkClaimStep + + /** The early claim fits the user's cap, so Spark makes it by itself. */ + data object Adding : SparkClaimStep + + /** No early claim on offer. */ + data object Unavailable : SparkClaimStep + + /** Within the ceiling the user accepted: claim it now. */ + data class ClaimEarly(val offer: SparkClaimOffer.Choice) : SparkClaimStep + + /** Above the ceiling: ask the user. */ + data class Ask(val offer: SparkClaimOffer.Choice) : SparkClaimStep + + companion object { + fun of(offer: SparkClaimOffer?, ceilingSats: ULong): SparkClaimStep = when (offer) { + null -> KeepLooking + is SparkClaimOffer.Automatic -> Adding + is SparkClaimOffer.Standard -> Unavailable + is SparkClaimOffer.Choice if offer.earlyFeeSats <= ceilingSats -> ClaimEarly(offer) + is SparkClaimOffer.Choice -> Ask(offer) + } + } +} + +/** What accepting the early claim of [offer] leaves the deposit at. */ +fun SparkEarlyClaim.toDepositClaim(offer: SparkClaimOffer.Choice): SparkDepositClaim = when (this) { + SparkEarlyClaim.Claimed -> SparkDepositClaim.Adding + SparkEarlyClaim.Waiting -> SparkDepositClaim.Looking + is SparkEarlyClaim.Repriced -> SparkDepositClaim.Repriced(offer.copy(earlyFeeSats = requiredFeeSats)) + SparkEarlyClaim.Declined -> SparkDepositClaim.Unavailable +} diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index e67c95d1a2..d988881a95 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -15,7 +15,9 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -76,6 +78,7 @@ class WalletRepo @Inject constructor( private val activityRepo: ActivityRepo, private val hwWalletRepo: HwWalletRepo, private val connectionRecovery: WalletConnectionRecovery, + private val sparkRepo: SparkRepo, ) { private val repoScope = appScope(bgDispatcher, TAG) @@ -115,6 +118,16 @@ class WalletRepo @Inject constructor( _balanceState.update { state -> state.copy(hardwareWallets = wallets.map { it.toBalance() }) } } } + repoScope.launch { + sparkRepo.state.map { it.isReady }.distinctUntilChanged().collect { isReady -> + if (isReady && walletExists()) refreshBip21() + } + } + repoScope.launch { + sparkRepo.state.map { it.balanceSats to it.pendingDepositSats }.distinctUntilChanged().collect { + if (walletExists()) syncBalances() + } + } } private suspend fun updateReceiveState( @@ -414,7 +427,8 @@ class WalletRepo @Inject constructor( fun refreshMaxSendLightning() { val state = lightningRepo.lightningState.value if (!state.nodeLifecycleState.isRunning()) return - _balanceState.update { it.copy(maxSendLightningSats = state.channels.totalNextOutboundHtlcLimitSats()) } + val maxSendSats = maxOf(state.channels.totalNextOutboundHtlcLimitSats(), sparkRepo.state.value.sendableSats) + _balanceState.update { it.copy(maxSendLightningSats = maxSendSats) } } suspend fun syncBalances() { @@ -991,11 +1005,15 @@ class WalletRepo @Inject constructor( } } + /** Spark receives any amount without inbound liquidity, so no channel or CJIT is needed. */ + fun canReceiveWithSpark(): Boolean = sparkRepo.canReceive() + suspend fun inboundLiquiditySats(): ULong = withContext(bgDispatcher) { return@withContext currentReadyChannels().calculateRemoteBalance() } private fun canCreateLightningInvoice(amountSats: ULong?): Boolean { + if (sparkRepo.canReceive()) return true val readyChannels = currentReadyChannels() return ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels = readyChannels.isNotEmpty(), diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index c0ff8e83b1..d30f8f2af4 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -32,6 +32,7 @@ import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Config import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.ElectrumSyncConfig +import org.lightningdevkit.ldknode.EsploraSyncConfig import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind @@ -398,6 +399,20 @@ class LightningService internal constructor( } private suspend fun Builder.configureChainSource(customServerUrl: String? = null) { + Env.regtestEsploraUrl?.let { esploraUrl -> + Logger.info("Using onchain source Esplora url: '$esploraUrl'", context = TAG) + setChainSourceEsplora( + serverUrl = esploraUrl, + config = EsploraSyncConfig( + BackgroundSyncConfig( + onchainWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, + lightningWalletSyncIntervalSecs = Env.walletSyncIntervalSecs, + feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs, + ), + ), + ) + return + } val serverUrl = customServerUrl ?: settingsStore.data.first().electrumServer Logger.info("Using onchain source Electrum Sever url: $serverUrl", context = TAG) setChainSourceElectrum( diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 9e47625fde..b68150ae07 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -999,6 +999,8 @@ 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, ) } @@ -2144,6 +2146,8 @@ 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/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt new file mode 100644 index 0000000000..e11294d0b8 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -0,0 +1,266 @@ +package to.bitkit.services + +import breez_sdk_spark.BreezSdk +import breez_sdk_spark.ClaimDepositOutcome +import breez_sdk_spark.ClaimDepositRequest +import breez_sdk_spark.Config +import breez_sdk_spark.ConnectRequest +import breez_sdk_spark.DepositInfo +import breez_sdk_spark.EventListener +import breez_sdk_spark.FeePolicy +import breez_sdk_spark.FetchClaimDepositQuoteRequest +import breez_sdk_spark.FetchClaimDepositQuoteResponse +import breez_sdk_spark.GetInfoRequest +import breez_sdk_spark.GetInfoResponse +import breez_sdk_spark.ImportUnilateralExitStateRequest +import breez_sdk_spark.ImportUnilateralExitStateResponse +import breez_sdk_spark.ListPaymentsRequest +import breez_sdk_spark.ListUnclaimedDepositsRequest +import breez_sdk_spark.MaxFee +import breez_sdk_spark.MaxOnchainFee +import breez_sdk_spark.OnchainConfirmationSpeed +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentRequest +import breez_sdk_spark.PrepareSendPaymentRequest +import breez_sdk_spark.PrepareSendPaymentResponse +import breez_sdk_spark.ReceivePaymentMethod +import breez_sdk_spark.ReceivePaymentRequest +import breez_sdk_spark.SdkEvent +import breez_sdk_spark.Seed +import breez_sdk_spark.SendPaymentMethod +import breez_sdk_spark.SendPaymentOptions +import breez_sdk_spark.SendPaymentRequest +import breez_sdk_spark.SyncWalletRequest +import breez_sdk_spark.connect +import breez_sdk_spark.defaultConfig +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.lightningdevkit.ldknode.Network +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.models.safe +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import java.io.File +import java.math.BigInteger +import javax.inject.Inject +import javax.inject.Singleton +import breez_sdk_spark.Network as SparkNetwork + +/** + * Wraps the Breez SDK on Spark, which keeps Lightning available without channels after Blocktank stopped + * selling them. The Spark wallet derives its keys from the Bitkit seed on its own path (`m/8797555'`). + */ +@Singleton +class SparkService @Inject constructor( + private val keychain: Keychain, +) { + companion object { + private const val TAG = "SparkService" + + /** + * Bitkit's settings over the SDK's [defaults]. Breez's real-time data sync server and its Lightning address + * (LNURL) server stay off on every network. + */ + internal fun configure(defaults: Config, apiKey: String?): Config = defaults.apply { + this.apiKey = apiKey + realTimeSyncServerUrl = null + lnurlDomain = null + // Lowering it below the spread keeps the deposit from being claimed early, and does not hold back its + // claim at maturity. + maxDepositClaimFee = MaxFee( + onchain = MaxOnchainFee.NetworkRecommended(DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB), + proportionalPpm = null, + ) + } + + /** Leeway in sat/vB over the recommended fee that an automatic deposit claim may pay. */ + private const val DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB = 2uL + + /** Share of a deposit, in parts per million, that an Instant claim may pay on top of its on-chain fee. */ + private const val DEPOSIT_CLAIM_FEE_PPM = 2_000u + + private const val PPM_DIVISOR = 1_000_000uL + + /** Size of the transaction that claims a deposit, which prices the on-chain part of its fee. */ + private const val CLAIM_TX_SIZE_VBYTES = 99uL + + /** + * The most an Instant claim of a deposit of [amountSats] may cost, at the fastest recommended fee rate + * [fastestFeeRate]: the ceiling the user accepts. The provider quotes the real fee only once the deposit + * exists. + */ + fun maxDepositClaimFeeSats(fastestFeeRate: ULong, amountSats: ULong): ULong { + val rate = fastestFeeRate.safe() + DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB.safe() + val onchain = rate.safe() * CLAIM_TX_SIZE_VBYTES.safe() + val proportional = amountSats.safe() * DEPOSIT_CLAIM_FEE_PPM.toULong().safe() / PPM_DIVISOR + return onchain.safe() + proportional.safe() + } + + /** What Spark keeps to claim a deposit at 3 confirmations, at the half-hour fee rate [halfHourFeeRate]. */ + fun matureDepositClaimFeeSats(halfHourFeeRate: ULong): ULong = + halfHourFeeRate.safe() * CLAIM_TX_SIZE_VBYTES.safe() + + /** + * Returns as soon as the payment starts, like ldk-node, so its completion reaches callers as an event they + * are already watching for; waiting for completion here fired that event before they watched. + */ + fun bolt11SendOptions(prefersSpark: Boolean): SendPaymentOptions = + SendPaymentOptions.Bolt11Invoice(preferSpark = prefersSpark, completionTimeoutSecs = null) + } + + private val mutex = Mutex() + private var sdk: BreezSdk? = null + private var listenerId: String? = null + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _events.asSharedFlow() + + /** Spark runs on mainnet with a Breez API key, and on Lightspark's hosted regtest, which needs none. */ + val isSupported: Boolean + get() = when (Env.network) { + Network.BITCOIN -> Env.breezApiKey != null + Network.REGTEST -> true + else -> false + } + + val isConnected: Boolean get() = sdk != null + + /** + * Spark's regtest has no Lightning network, so there a Lightning invoice from another Spark wallet is paid as a + * Spark transfer, which lets two dev builds pay each other's invoices. + */ + val prefersSpark: Boolean get() = Env.network == Network.REGTEST + + suspend fun connect(walletIndex: Int) = mutex.withLock { + if (sdk != null) return@withLock + val network = sparkNetwork() ?: throw SparkError.Unsupported() + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + val config = configure(defaultConfig(network), Env.breezApiKey) + val storageDir = File(Env.sparkStoragePath(walletIndex)).apply { mkdirs() }.path + val connected = connect(ConnectRequest(config, Seed.Mnemonic(mnemonic, passphrase), storageDir)) + listenerId = connected.addEventListener( + object : EventListener { + override suspend fun onEvent(event: SdkEvent) { + _events.emit(event) + } + }, + ) + sdk = connected + Logger.info("Connected Spark wallet on '$network'", context = TAG) + } + + suspend fun disconnect() = mutex.withLock { + val current = sdk ?: return@withLock + listenerId?.let { current.removeEventListener(it) } + current.disconnect() + sdk = null + listenerId = null + Logger.info("Disconnected Spark wallet", context = TAG) + } + + suspend fun wipeStorage(walletIndex: Int) { + disconnect() + File(Env.sparkStoragePath(walletIndex)).deleteRecursively() + } + + /** Syncs with Spark now instead of waiting for the SDK's next background sync. */ + suspend fun syncWallet() { + requireSdk().syncWallet(SyncWalletRequest) + } + + suspend fun getInfo(): GetInfoResponse = requireSdk().getInfo(GetInfoRequest(ensureSynced = false)) + + suspend fun receiveBolt11(amountSats: ULong?, description: String, expirySecs: UInt): String { + val method = ReceivePaymentMethod.Bolt11Invoice(description, amountSats, expirySecs, null, null) + return requireSdk().receivePayment(ReceivePaymentRequest(method)).paymentRequest + } + + /** Returns the static on-chain address that funds the Spark wallet, claimed automatically once confirmed. */ + suspend fun depositAddress(): String { + val method = ReceivePaymentMethod.BitcoinAddress(newAddress = null) + return requireSdk().receivePayment(ReceivePaymentRequest(method)).paymentRequest + } + + suspend fun prepareSend( + paymentRequest: String, + amountSats: ULong?, + feePolicy: FeePolicy? = null, + ): PrepareSendPaymentResponse = + requireSdk().prepareSendPayment( + PrepareSendPaymentRequest( + paymentRequest = PaymentRequest.Input(input = paymentRequest), + amount = amountSats?.let { BigInteger.valueOf(it.toLong()) }, + tokenIdentifier = null, + conversionOptions = null, + feePolicy = feePolicy, + ), + ) + + suspend fun send(prepared: PrepareSendPaymentResponse): Payment { + val options = when (prepared.paymentMethod) { + is SendPaymentMethod.Bolt11Invoice -> bolt11SendOptions(prefersSpark) + is SendPaymentMethod.BitcoinAddress -> SendPaymentOptions.BitcoinAddress(OnchainConfirmationSpeed.MEDIUM) + else -> null + } + return requireSdk().sendPayment(SendPaymentRequest(prepared, options)).payment + } + + /** The fastest recommended fee rate, in sat/vB, from the SDK's own fee source. */ + suspend fun fastestFeeRate(): ULong = requireSdk().recommendedFees().fastestFee + + /** The half-hour recommended fee rate, in sat/vB, from the SDK's own fee source. */ + suspend fun halfHourFeeRate(): ULong = requireSdk().recommendedFees().halfHourFee + + suspend fun listUnclaimedDeposits(): List = + requireSdk().listUnclaimedDeposits(ListUnclaimedDepositsRequest).deposits + + /** Quotes the early and the standard claim of a deposit; the early quote asks the provider on every call. */ + suspend fun fetchClaimQuote(txid: String, vout: UInt): FetchClaimDepositQuoteResponse = + requireSdk().fetchClaimDepositQuote(FetchClaimDepositQuoteRequest(txid, vout)) + + /** + * Claims a deposit with its own fee ceiling of [maxFeeSats], which the SDK keeps on the deposit, so an early + * claim not offered at this depth is still made once the provider offers it. + */ + suspend fun claimDeposit(txid: String, vout: UInt, maxFeeSats: ULong): ClaimDepositOutcome = + requireSdk().claimDeposit( + ClaimDepositRequest(txid, vout, MaxFee(MaxOnchainFee.Fixed(maxFeeSats), proportionalPpm = null)), + ).outcome + + suspend fun listPayments(): List = requireSdk().listPayments(ListPaymentsRequest()).payments + + /** Exports what a unilateral exit needs if the Spark operators stop cooperating; it can reach several MB. */ + suspend fun exportExitState(): String = requireSdk().exportUnilateralExitState().exitState + + /** Merges an exported exit state back in, without contacting the Spark operators. */ + suspend fun importExitState(exitState: String): ImportUnilateralExitStateResponse = + requireSdk().importUnilateralExitState(ImportUnilateralExitStateRequest(exitState)) + + /** The Spark network this build runs on, as exit data backups record it. */ + val networkName: String? get() = sparkNetwork()?.name?.lowercase() + + private fun requireSdk(): BreezSdk = sdk ?: throw SparkError.NotConnected() + + private fun sparkNetwork(): SparkNetwork? = when (Env.network) { + Network.BITCOIN if Env.breezApiKey != null -> SparkNetwork.MAINNET + Network.REGTEST -> SparkNetwork.REGTEST + else -> null + } +} + +sealed class SparkError(message: String, cause: Throwable? = null) : AppError(message, cause) { + class Unsupported : SparkError("Spark is not available on this network or build") + class NotConnected : SparkError("Spark wallet is not connected") + + /** + * Spark refused a Lightning payment before anything left the wallet: it was not connected, could not prepare the + * payment, or its fee was over the caller's limit. A Paykit request or allowance attempt can then end as failed. + */ + class SendRefused(message: String, cause: Throwable? = null) : SparkError(message, cause) +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 64a589f578..c0789dd495 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -4,6 +4,7 @@ package to.bitkit.ui import android.Manifest import android.content.Intent import android.os.Build +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box @@ -140,12 +141,21 @@ 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.SparkFundingViewModel +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.TransferLearnMoreScreen 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.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 @@ -194,6 +204,7 @@ import to.bitkit.ui.settings.lightning.ChannelDetailScreen import to.bitkit.ui.settings.lightning.CloseConnectionScreen import to.bitkit.ui.settings.lightning.LightningConnectionsScreen import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel +import to.bitkit.ui.settings.lightning.SparkDetailScreen import to.bitkit.ui.settings.pin.PinManagementScreen import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen import to.bitkit.ui.settings.quickPay.QuickPaySettingsScreen @@ -1026,6 +1037,34 @@ private fun RootNavHost( navigationWithDefaultTransitions( startDestination = Routes.SavingsIntro, ) { + deepLinkableComposable { + TransferIntroScreen( + onContinueClick = { + navController.navigateToTransferFunding() + settingsViewModel.setHasSeenTransferIntro(true) + }, + onBackClick = { navController.popBackStack() }, + ) + } + deepLinkableComposable { + SpendingIntroScreen( + onContinueClick = { + navController.navigateTo(Routes.SparkFundingNav) + 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 { SavingsIntroScreen( onContinueClick = { @@ -1067,11 +1106,105 @@ private fun RootNavHost( ) } deepLinkableComposable { + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() FundingScreen( + canFundWithSpark = sparkState.isReady, + onTransfer = { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) }, + onFund = { + scope.launch { + navController.navigateToHome() + delay(500) // Wait for nav to actually finish + appViewModel.showSheet(Sheet.Receive()) + } + }, onManual = { navController.navigateTo(Routes.ExternalNav) }, onBackClick = { navController.popBackStack() }, ) } + navigationWithDefaultTransitions( + startDestination = Routes.SpendingAmount, + ) { + deepLinkableComposable { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SparkFundingNav) } + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() + SpendingAmountScreen( + viewModel = hiltViewModel(parentEntry), + isOffline = connectivityState != ConnectivityState.CONNECTED, + onBackClick = { navController.popBackStack() }, + onQuoteReady = { navController.navigateTo(Routes.SpendingConfirm) }, + ) + } + deepLinkableComposable { entry -> + val walletId = entry.toRoute().walletId + val parentEntry = remember(entry) { navController.getBackStackEntry(Routes.SparkFundingNav) } + val viewModel = hiltViewModel(parentEntry) + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() + LaunchedEffect(walletId) { viewModel.useHardwareWallet(walletId) } + SpendingAmountScreen( + viewModel = viewModel, + isOffline = connectivityState != ConnectivityState.CONNECTED, + onBackClick = { navController.popBackStack() }, + onQuoteReady = { navController.navigateTo(Routes.SpendingHwSign(walletId)) }, + ) + } + composableWithDefaultTransitions { entry -> + val walletId = entry.toRoute().walletId + val parentEntry = remember(entry) { navController.getBackStackEntry(Routes.SparkFundingNav) } + SpendingHwSignScreen( + walletId = walletId, + viewModel = hiltViewModel(parentEntry), + onBackClick = { navController.popBackStack() }, + onLearnMoreClick = { navController.navigateTo(Routes.TransferLearnMore) }, + onSent = { + navController.navigateTo(Routes.SpendingHwSigned) { + popUpTo { inclusive = true } + } + }, + ) + } + composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SparkFundingNav) } + SpendingHwSignedScreen( + viewModel = hiltViewModel(parentEntry), + onContinue = { + navController.navigateTo(Routes.SettingUp) { + popUpTo { inclusive = true } + } + }, + onCloseClick = { navController.navigateToHome() }, + ) + } + composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SparkFundingNav) } + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() + SpendingConfirmScreen( + viewModel = hiltViewModel(parentEntry), + isOffline = connectivityState != ConnectivityState.CONNECTED, + onBackClick = { navController.popBackStack() }, + onLearnMoreClick = { navController.navigateTo(Routes.TransferLearnMore) }, + onSent = { navController.navigateToSettingUp() }, + ) + } + composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SparkFundingNav) } + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + TransferLearnMoreScreen( + viewModel = hiltViewModel(parentEntry), + spendingBalanceSats = sparkState.balanceSats.toLong(), + onBackClick = { navController.popBackStack() }, + onContinueClick = { navController.popBackStack() }, + ) + } + composableWithDefaultTransitions { + val parentEntry = remember(it) { navController.getBackStackEntry(Routes.SparkFundingNav) } + BackHandler { navController.navigateToHome() } + SettingUpScreen( + viewModel = hiltViewModel(parentEntry), + onContinueClick = { navController.navigateToHome() }, + ) + } + } navigationWithDefaultTransitions( startDestination = ExternalConnection(), ) { @@ -1202,6 +1335,8 @@ private fun NavGraphBuilder.home( ) } deepLinkableComposable { + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val onchainActivities by activityListViewModel.onchainActivities.collectAsStateWithLifecycle() val forceCloseRemainingDuration by appViewModel.forceCloseRemainingDuration.collectAsStateWithLifecycle() @@ -1210,18 +1345,27 @@ private fun NavGraphBuilder.home( onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, - onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, + onTransferToSpendingClick = { + if (sparkState.isReady) { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + } else { + appViewModel.showSheet(Sheet.TransferUnavailable) + } + }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, ) } deepLinkableComposable { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() + val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() SpendingWalletScreen( channels = lightningState.channels, + hasSparkBalance = sparkState.balanceSats > 0uL, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, @@ -1233,16 +1377,30 @@ private fun NavGraphBuilder.home( navController.navigateToTransferSavingsAvailability() } }, - onTransferFromSavingsClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, + onTransferFromSavingsClick = { + if (sparkState.isReady) { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) + } else { + appViewModel.showSheet(Sheet.TransferUnavailable) + } + }, onBackClick = { navController.popBackStack() }, ) } deepLinkableComposable { val walletId = it.toRoute().walletId + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( walletId = walletId, onActivityItemClick = { navController.navToActivityDetail(it) }, - onTransferToSpendingClick = { appViewModel.showSheet(Sheet.TransferUnavailable) }, + onTransferToSpendingClick = { walletId -> + if (sparkState.isReady) { + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, walletId) + } else { + appViewModel.showSheet(Sheet.TransferUnavailable) + } + }, onBackClick = { navController.popBackStack() }, ) } @@ -1831,6 +1989,20 @@ private fun NavGraphBuilder.lightningConnections( navController = navController, ) } + deepLinkableComposable { + val hasSeenSavingsIntro = settingsViewModel?.hasSeenSavingsIntro + ?.collectAsStateWithLifecycle()?.value == true + SparkDetailScreen( + onBack = { navController.popBackStack() }, + onTransferToSavings = { + if (hasSeenSavingsIntro) { + navController.navigateToTransferSavingsAvailability() + } else { + navController.navigateToTransferSavingsIntro() + } + }, + ) + } deepLinkableComposable { val route = it.toRoute() CloseConnectionScreen( @@ -2112,6 +2284,29 @@ internal fun receiveSheetPresentationKey(sheet: Sheet.Receive): String = sheet.p fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) +fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) + +fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean) = + navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro)) + +internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes = when { + hasSeenSpendingIntro -> Routes.SparkFundingNav + else -> Routes.SpendingIntro +} + +fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean, walletId: String) = + navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, walletId)) + +internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean, walletId: String): Routes = when { + hasSeenSpendingIntro -> Routes.SpendingAmountHw(walletId) + else -> Routes.SpendingIntroHw(walletId) +} + +/** Shows the transfer's progress in place of its confirm screen, so going back cannot swipe it again. */ +fun NavController.navigateToSettingUp() = navigateTo(Routes.SettingUp) { + popUpTo { inclusive = true } +} + fun NavController.navToActivityDetail(activity: Activity) = navigateTo( Routes.ActivityDetail( id = activity.rawId(), @@ -2273,6 +2468,9 @@ sealed interface Routes { @Serializable data class ChannelDetail(val channelId: String) : Routes.DeepLinkable + @Serializable + data object SparkDetail : Routes.DeepLinkable + @Serializable data class CloseConnection(val channelId: String) : Routes.DeepLinkable @@ -2300,6 +2498,15 @@ 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 SavingsIntro : Routes.DeepLinkable @@ -2321,6 +2528,30 @@ sealed interface Routes { @Serializable data object ExternalNav : Routes.DeepLinkable + @Serializable + data object SparkFundingNav : Routes.InternalOnly + + @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 TransferLearnMore : Routes.InternalOnly + + @Serializable + data object SettingUp : Routes.InternalOnly + @Serializable data class ExternalConnection(val scannedNodeUri: String? = null) : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/components/FeeInfo.kt b/app/src/main/java/to/bitkit/ui/components/FeeInfo.kt index 9f3b092b64..2a56a23f92 100644 --- a/app/src/main/java/to/bitkit/ui/components/FeeInfo.kt +++ b/app/src/main/java/to/bitkit/ui/components/FeeInfo.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.components import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -17,6 +18,7 @@ fun RowScope.FeeInfo( label: String, amount: Long, modifier: Modifier = Modifier, + prefix: String? = null, ) { Column( modifier = modifier @@ -29,7 +31,10 @@ fun RowScope.FeeInfo( color = Colors.White64, ) Spacer(modifier = Modifier.height(8.dp)) - MoneySSB(sats = amount, showSymbol = true) + Row { + prefix?.let { BodySSB(text = "$it ", color = Colors.White64) } + MoneySSB(sats = amount, showSymbol = true) + } Spacer(modifier = Modifier.weight(1f)) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) } diff --git a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt index 2b604ebe71..bdcb5d046b 100644 --- a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt +++ b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt @@ -39,8 +39,10 @@ fun LightningChannel( modifier: Modifier = Modifier, status: ChannelStatusUi = ChannelStatusUi.PENDING, showLabels: Boolean = false, + showReceiving: Boolean = true, ) { - val spendingColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray5 else Colors.Purple50 + val isEmptySpendingBar = !showReceiving && localBalance == 0L + val spendingColor = if (status == ChannelStatusUi.CLOSED || isEmptySpendingBar) Colors.Gray5 else Colors.Purple50 val spendingAvailableColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray3 else Colors.Purple val receivingColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray5 else Colors.White64 val receivingAvailableColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray3 else Colors.White @@ -58,7 +60,9 @@ fun LightningChannel( modifier = Modifier.fillMaxWidth(), ) { Caption13Up(text = stringResource(R.string.lightning__spending_label), color = Colors.White64) - Caption13Up(text = stringResource(R.string.lightning__receiving_label), color = Colors.White64) + if (showReceiving) { + Caption13Up(text = stringResource(R.string.lightning__receiving_label), color = Colors.White64) + } } Spacer(modifier = Modifier.height(8.dp)) } @@ -78,17 +82,19 @@ fun LightningChannel( color = spendingAvailableColor, ) } - Row(verticalAlignment = CenterVertically) { - Icon( - painter = painterResource(R.drawable.ic_received), - contentDescription = null, - tint = receivingAvailableColor, - modifier = Modifier.size(14.dp) - ) - MoneyCaptionB( - sats = remoteBalance, - color = receivingAvailableColor, - ) + if (showReceiving) { + Row(verticalAlignment = CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_received), + contentDescription = null, + tint = receivingAvailableColor, + modifier = Modifier.size(14.dp) + ) + MoneyCaptionB( + sats = remoteBalance, + color = receivingAvailableColor, + ) + } } } Spacer(modifier = Modifier.height(8.dp)) @@ -102,16 +108,17 @@ fun LightningChannel( modifier = Modifier .weight(1f) .fillMaxHeight() - .background(spendingColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) + .background(spendingColor, if (showReceiving) SpendingHalfShape else FullBarShape) ) { Box( modifier = Modifier .fillMaxHeight() .fillMaxWidth(spendingAvailableFraction) .align(Alignment.CenterEnd) - .background(spendingAvailableColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) + .background(spendingAvailableColor, if (showReceiving) SpendingHalfShape else FullBarShape) ) } + if (!showReceiving) return@Row Spacer(modifier = Modifier.width(4.dp)) Box( modifier = Modifier @@ -130,6 +137,9 @@ fun LightningChannel( } } +private val SpendingHalfShape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp) +private val FullBarShape = RoundedCornerShape(8.dp) + // region preview @Preview(showBackground = true) 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 24a4810270..8af2625d6c 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 @@ -3,9 +3,16 @@ package to.bitkit.ui.screens.transfer import android.content.Intent import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth 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.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 @@ -16,12 +23,13 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import to.bitkit.R 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 @@ -31,48 +39,87 @@ import to.bitkit.ui.utils.withAccent @Composable fun FundingScreen( + canFundWithSpark: Boolean = false, + onTransfer: () -> Unit = {}, + onFund: () -> Unit = {}, onManual: () -> Unit = {}, onBackClick: () -> Unit = {}, ) { val context = LocalContext.current + val hasSavings = LocalBalances.current.maxSendOnchainSats > 0uL + var showNoFundsAlert by remember { mutableStateOf(false) } ScreenColumn { AppTopBar( titleText = stringResource(R.string.lightning__funding__nav_title), onBackClick = onBackClick, actions = { DrawerNavIcon() }, ) - 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, + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .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() + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + 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( + if (canFundWithSpark) { + R.string.lightning__funding__text + } else { + R.string.lightning__transfer_unavailable__text + } + ), + color = Colors.White64, + modifier = Modifier.padding(horizontal = 16.dp), + ) + VerticalSpacer(24.dp) + // Spark keeps Spending fundable from savings or another wallet without a channel. + if (canFundWithSpark) { + FundingOption( + R.string.lightning__funding__button1, + R.drawable.ic_transfer, + 20.dp, + "FundTransfer", + { if (hasSavings) onTransfer() else showNoFundsAlert = true }, + ) + FundingOption( + R.string.lightning__funding__button2, + R.drawable.ic_qr_purple, + 13.75.dp, + "FundReceive", + onFund + ) + } + VerticalSpacer(8.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) + .testTag("FundManual") + ) + if (!canFundWithSpark) { + FundingOption(R.string.lightning__funding__button1, R.drawable.ic_transfer, 20.dp, "FundTransfer") + } + FundingOption( + R.string.lightning__funding__button_hw, + R.drawable.ic_device_tablet_speaker, + 16.25.dp, + "FundHardware" + ) + if (!canFundWithSpark) { + FundingOption(R.string.lightning__funding__button2, R.drawable.ic_qr_purple, 13.75.dp, "FundReceive") + } + VerticalSpacer(32.dp) + } SecondaryButton( text = stringResource(R.string.common__learn_more), onClick = { @@ -83,15 +130,29 @@ fun FundingScreen( .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 }, + ) + } } } @Composable -private fun UnavailableOption( +private fun FundingOption( @StringRes label: Int, @DrawableRes icon: Int, iconSize: Dp, testTag: String, + onClick: (() -> Unit)? = null, ) { VerticalSpacer(8.dp) RectangleButton( @@ -99,8 +160,8 @@ private fun UnavailableOption( icon = icon, iconTint = Colors.Purple, iconSize = iconSize, - enabled = false, - onClick = {}, + enabled = onClick != null, + onClick = { onClick?.invoke() }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) @@ -115,3 +176,11 @@ private fun Preview() { FundingScreen() } } + +@Preview(showSystemUi = true) +@Composable +private fun PreviewSpark() { + AppThemeSurface { + FundingScreen(canFundWithSpark = true) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt index 1116b50a89..b2c7f99962 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.filterOpen @@ -47,6 +48,7 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.transferViewModel import to.bitkit.ui.utils.withAccent import to.bitkit.ui.walletViewModel +import to.bitkit.viewmodels.SavingsSelection @Composable fun SavingsAdvancedScreen( @@ -60,7 +62,11 @@ fun SavingsAdvancedScreen( val lightningState by wallet.lightningState.collectAsStateWithLifecycle() val openChannels = lightningState.channels.filterOpen() + val sparkState by wallet.sparkState.collectAsStateWithLifecycle() + val sparkSats = sparkState.balanceSats + var selectedChannelIds by remember { mutableStateOf(setOf()) } + var isSparkSelected by remember { mutableStateOf(true) } // Select all open channels on init LaunchedEffect(Unit) { @@ -85,13 +91,20 @@ fun SavingsAdvancedScreen( }.toImmutableList() } + val sparkItem = sparkSats.takeIf { it > 0uL }?.let { SparkUiState(balance = it, isSelected = isSparkSelected) } + SavingsAdvancedContent( channelItems = channelItems, + sparkItem = sparkItem, onChannelItemClick = { channelId -> toggleChannel(channelId) }, + onSparkItemClick = { isSparkSelected = !isSparkSelected }, onAmountClick = { currency.switchUnit() }, onContinueClick = { - transfer.setSelectedChannelIds( - selectedChannelIds.takeUnless { it.size == openChannels.size } ?: emptySet() + transfer.setSavingsSelection( + SavingsSelection( + channelIds = selectedChannelIds.takeUnless { it.size == openChannels.size }?.toImmutableSet(), + includesSpark = isSparkSelected, + ) ) onContinueClick() }, @@ -102,12 +115,15 @@ fun SavingsAdvancedScreen( @Composable private fun SavingsAdvancedContent( channelItems: ImmutableList, + sparkItem: SparkUiState? = null, onChannelItemClick: (String) -> Unit = {}, + onSparkItemClick: () -> Unit = {}, onAmountClick: () -> Unit = {}, onContinueClick: () -> Unit = {}, onBackClick: () -> Unit = {}, ) { - val totalAmount = channelItems.filter { it.isSelected }.sumOf { it.balance } + val sparkAmount = sparkItem?.takeIf { it.isSelected }?.balance ?: 0uL + val totalAmount = channelItems.filter { it.isSelected }.sumOf { it.balance } + sparkAmount ScreenColumn { AppTopBar( @@ -132,6 +148,16 @@ private fun SavingsAdvancedContent( LazyColumn( modifier = Modifier.weight(1f) ) { + sparkItem?.let { spark -> + item(key = SPARK_ITEM_KEY) { + ChannelItem( + channelName = stringResource(R.string.lightning__spark), + balanceSat = spark.balance, + iSelected = spark.isSelected, + onClick = onSparkItemClick, + ) + } + } itemsIndexed( items = channelItems.sortedBy { it.channelId }, key = { _, channel -> channel.channelId }, @@ -153,7 +179,7 @@ private fun SavingsAdvancedContent( PrimaryButton( text = stringResource(R.string.common__continue), onClick = onContinueClick, - enabled = channelItems.any { it.isSelected }, + enabled = channelItems.any { it.isSelected } || sparkItem?.isSelected == true, ) Spacer(modifier = Modifier.height(16.dp)) @@ -196,6 +222,14 @@ fun ChannelItem( } } +private const val SPARK_ITEM_KEY = "spark" + +@Immutable +private data class SparkUiState( + val balance: ULong, + val isSelected: Boolean, +) + @Immutable private data class TransferChannelUiState( val channelId: String, @@ -225,6 +259,7 @@ private fun SavingsAdvancedScreenPreview() { isSelected = false, ), ), + sparkItem = SparkUiState(balance = 12_000u, isSelected = true), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index 4b18c10d88..743cdc390a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt @@ -6,15 +6,19 @@ 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.BoxWithConstraints 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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 @@ -33,10 +37,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.filterOpen +import to.bitkit.ext.totalNextOutboundHtlcLimitSats +import to.bitkit.models.safe import to.bitkit.ui.components.AmountSlider import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up @@ -58,6 +63,7 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.transferViewModel import to.bitkit.ui.utils.withAccent import to.bitkit.ui.walletViewModel +import to.bitkit.viewmodels.SavingsSelection import to.bitkit.viewmodels.SavingsSwapQuote import to.bitkit.viewmodels.SavingsTransferMode @@ -75,20 +81,23 @@ fun SavingsConfirmScreen( val lightningState by wallet.lightningState.collectAsStateWithLifecycle() val openChannels = lightningState.channels.filterOpen() - val hasMultiple = openChannels.size > 1 + val sparkState by wallet.sparkState.collectAsStateWithLifecycle() + val sparkSats = sparkState.balanceSats - val selectedChannelIds by transfer.selectedChannelIdsState.collectAsStateWithLifecycle() - val selectedChannels: List? = selectedChannelIds - .takeIf { it.isNotEmpty() } - ?.let { list -> openChannels.filter { channel -> list.contains(channel.channelId) } } + // Spark counts as one more item: the selection list is offered once there are two to choose from. + val hasMultiple = openChannels.size + (if (sparkSats > 0uL) 1 else 0) > 1 - val hasSelected = selectedChannelIds.isNotEmpty() + val selection by transfer.savingsSelection.collectAsStateWithLifecycle() + val hasSelected = !selection.isAll - val channels = selectedChannels ?: openChannels + val channels = selection.channels(openChannels) + val withdrawsSpark = selection.withdrawsSpark(sparkSats) - val amount = channels.sumOf { it.amountOnClose } + val channelsAmount = channels.sumOf { it.amountOnClose } + val sparkAmount = if (withdrawsSpark) sparkSats else 0uL val swapState by transfer.savingsSwapState.collectAsStateWithLifecycle() + val sparkFeeSats by transfer.sparkWithdrawFeeSats.collectAsStateWithLifecycle() // Pull the latest node balances so a just-received payment is reflected in the amounts below. LaunchedEffect(Unit) { @@ -96,13 +105,20 @@ fun SavingsConfirmScreen( } // Present the swap fee before the user commits. Recomputed when the amount changes. - LaunchedEffect(amount) { - if (amount > 0uL) transfer.loadSavingsSwapQuote(amount) + // A zero amount clears an earlier quote, so a Spark-only transfer never swaps channel funds. + LaunchedEffect(channelsAmount) { + transfer.loadSavingsSwapQuote(channelsAmount, channels.totalNextOutboundHtlcLimitSats()) + } + + LaunchedEffect(withdrawsSpark, sparkSats) { + if (withdrawsSpark) transfer.loadSparkWithdrawFee(sparkSats) } Box { SavingsConfirmContent( - fallbackAmount = amount, + fallbackAmount = channelsAmount, + sparkAmount = sparkAmount, + sparkFeeSats = sparkFeeSats.takeIf { withdrawsSpark }, isSwapEnabled = swapState.isSwapEnabled, quote = swapState.quote, isQuoteLoading = swapState.isLoading, @@ -114,13 +130,13 @@ fun SavingsConfirmScreen( onBackClick = onBackClick, onAmountClick = { currency.switchUnit() }, onAdvancedClick = onAdvancedClick, - onSelectAllClick = { transfer.setSelectedChannelIds(emptySet()) }, + onSelectAllClick = { transfer.setSavingsSelection(SavingsSelection()) }, onSwipeConfirm = { - transfer.onTransferToSavingsConfirm(channels) + transfer.onTransferToSavingsConfirm(channels, withdrawsSpark) onConfirm() }, onCloseConfirm = { - transfer.onTransferToSavingsConfirm(channels, SavingsTransferMode.CLOSE) + transfer.onTransferToSavingsConfirm(channels, withdrawsSpark, SavingsTransferMode.CLOSE) onConfirm() }, ) @@ -140,6 +156,8 @@ fun SavingsConfirmScreen( @Composable private fun SavingsConfirmContent( fallbackAmount: ULong, + sparkAmount: ULong, + sparkFeeSats: ULong?, isSwapEnabled: Boolean, quote: SavingsSwapQuote?, isQuoteLoading: Boolean, @@ -156,7 +174,9 @@ private fun SavingsConfirmContent( onCloseConfirm: () -> Unit = {}, ) { val scope = rememberCoroutineScope() - val headlineAmount = quote?.amountSat ?: fallbackAmount + val headlineAmount = (quote?.amountSat ?: fallbackAmount).safe() + sparkAmount.safe() + val sparkReceive = sparkAmount.safe() - (sparkFeeSats ?: 0uL).safe() + val receiveAmount = (quote?.receiveSat ?: 0uL).safe() + sparkReceive.safe() // Only show the swap-pricing spinner while swaps are enabled and a quote is still being fetched; // otherwise the swipe just falls back to closing the channel and there is nothing to wait for. val isPricingSwap = isSwapEnabled && quote == null && isQuoteLoading @@ -170,99 +190,127 @@ private fun SavingsConfirmContent( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxSize() - ) { - VerticalSpacer(32.dp) - Display(text = stringResource(R.string.lightning__transfer__confirm).withAccent()) - VerticalSpacer(32.dp) - - Caption13Up(text = stringResource(R.string.lightning__savings_confirm__label), color = Colors.White64) - VerticalSpacer(8.dp) - MoneyDisplay(sats = headlineAmount.toLong(), onClick = onAmountClick) - - if (quote != null) { - VerticalSpacer(24.dp) - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.height(IntrinsicSize.Min), - ) { - FeeInfo( - label = stringResource(R.string.lightning__savings_confirm__network_fee), - amount = quote.networkFeeSat.toLong(), - ) - FeeInfo( - label = stringResource(R.string.lightning__savings_confirm__service_fee), - amount = quote.swapFeeSat.toLong(), - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.height(IntrinsicSize.Min), - ) { - FeeInfo( - label = stringResource(R.string.lightning__savings_confirm__amount), - amount = quote.amountSat.toLong(), - ) - FeeInfo( - label = stringResource(R.string.lightning__savings_confirm__receive), - amount = quote.receiveSat.toLong(), - ) - } - - // Adjust how much to move to savings, bounded to a payable range. - if (maxSat > minSat) { - VerticalSpacer(28.dp) - AmountSlider( - value = quote.amountSat.toLong(), - min = minSat.toLong(), - max = maxSat.toLong(), - onValueChange = onAmountChange, - ) - } - } - - if (hasMultiple) { - VerticalSpacer(24.dp) - if (hasSelected) { - PrimaryButton( - text = stringResource(R.string.lightning__savings_confirm__transfer_all), - size = ButtonSize.Small, - fullWidth = false, - onClick = { onSelectAllClick() }, - ) - } else { - PrimaryButton( - text = stringResource(R.string.common__advanced), - size = ButtonSize.Small, - fullWidth = false, - onClick = { onAdvancedClick() }, - ) - } - } - - // Flexible middle: the piggybank fills the remaining space, so it shrinks when the - // fees/slider are shown (no squished buttons) and fills the gap while the quote loads. - Box( + BoxWithConstraints( modifier = Modifier .weight(1f) .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center, ) { - if (isPricingSwap) { - GradientCircularProgressIndicator( - modifier = Modifier.size(48.dp), - strokeWidth = 3.dp, + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + ) { + VerticalSpacer(32.dp) + Display(text = stringResource(R.string.lightning__transfer__confirm).withAccent()) + VerticalSpacer(32.dp) + + Caption13Up( + text = stringResource(R.string.lightning__savings_confirm__label), + color = Colors.White64, ) - } else { - Image( - painter = painterResource(R.drawable.piggybank), - contentDescription = null, - contentScale = ContentScale.Fit, + VerticalSpacer(8.dp) + MoneyDisplay(sats = headlineAmount.toLong(), onClick = onAmountClick) + + if (quote != null) { + VerticalSpacer(24.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min), + ) { + FeeInfo( + label = stringResource(R.string.lightning__savings_confirm__network_fee), + amount = quote.networkFeeSat.toLong(), + ) + FeeInfo( + label = stringResource(R.string.lightning__savings_confirm__service_fee), + amount = quote.swapFeeSat.toLong(), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min), + ) { + FeeInfo( + label = stringResource(R.string.lightning__savings_confirm__amount), + amount = headlineAmount.toLong(), + ) + FeeInfo( + label = stringResource(R.string.lightning__savings_confirm__receive), + amount = receiveAmount.toLong(), + ) + } + + // Adjust how much to move to savings, bounded to a payable range. + if (maxSat > minSat) { + VerticalSpacer(28.dp) + AmountSlider( + value = quote.amountSat.toLong(), + min = minSat.toLong(), + max = maxSat.toLong(), + onValueChange = onAmountChange, + ) + } + } + + if (sparkFeeSats != null) { + VerticalSpacer(if (quote != null) 0.dp else 24.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min), + ) { + FeeInfo( + label = stringResource(R.string.lightning__savings_confirm__spark_fee), + amount = sparkFeeSats.toLong(), + ) + } + } + + if (hasMultiple) { + VerticalSpacer(24.dp) + if (hasSelected) { + PrimaryButton( + text = stringResource(R.string.lightning__savings_confirm__transfer_all), + size = ButtonSize.Small, + fullWidth = false, + onClick = { onSelectAllClick() }, + ) + } else { + PrimaryButton( + text = stringResource(R.string.common__advanced), + size = ButtonSize.Small, + fullWidth = false, + onClick = { onAdvancedClick() }, + ) + } + } + + // Flexible middle: the piggybank fills the remaining space, so it shrinks when the + // fees/slider are shown (no squished buttons) and fills the gap while the quote loads. + Box( modifier = Modifier - .fillMaxSize() - .graphicsLayer(scaleX = -1f), - ) + .weight(1f) + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + if (isPricingSwap) { + GradientCircularProgressIndicator( + modifier = Modifier.size(48.dp), + strokeWidth = 3.dp, + ) + } else { + Image( + painter = painterResource(R.drawable.piggybank), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .graphicsLayer(scaleX = -1f), + ) + } + } } } @@ -300,6 +348,8 @@ private fun SavingsConfirmScreenPreview() { AppThemeSurface { SavingsConfirmContent( fallbackAmount = 50_123u, + sparkAmount = 12_000u, + sparkFeeSats = 1_470u, isSwapEnabled = true, quote = SavingsSwapQuote( amountSat = 50_123u, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt index cdf45cacdc..843ae080b3 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 @@ -59,6 +59,26 @@ fun SavingsProgressScreen( var progressState by remember { mutableStateOf(SavingsProgressState.PROGRESS) } val swapResult by transfer.savingsSwapResult.collectAsStateWithLifecycle() + // Spark's withdrawal failed: its balance stayed in Spending, so the transfer is not reported as done. + fun toastSparkFailure(movesChannels: Boolean) { + app.toast( + type = Toast.ToastType.ERROR, + title = resources.getString(R.string.common__error), + description = resources.getString( + if (movesChannels) { + R.string.lightning__savings_spark_failed_channels + } else { + R.string.lightning__savings_spark_failed + } + ), + ) + } + + fun showSparkFailure(movesChannels: Boolean) { + toastSparkFailure(movesChannels) + onTransferUnavailable() + } + // Effect to execute the transfer & update UI LaunchedEffect(Unit) { when (transfer.savingsTransferMode.value) { @@ -73,8 +93,17 @@ fun SavingsProgressScreen( SavingsTransferMode.CLOSE -> runChannelClose( transfer = transfer, wallet = wallet, - onSuccess = { progressState = SavingsProgressState.SUCCESS }, - onInterrupted = { progressState = SavingsProgressState.INTERRUPTED }, + onSuccess = { + if (transfer.isSparkWithdrawFailed.value) { + showSparkFailure(movesChannels = transfer.hasChannelsToClose()) + } else { + progressState = SavingsProgressState.SUCCESS + } + }, + onInterrupted = { + if (transfer.isSparkWithdrawFailed.value) toastSparkFailure(movesChannels = true) + progressState = SavingsProgressState.INTERRUPTED + }, onGiveUp = { app.showSheet(Sheet.ForceTransfer) }, ) } @@ -82,6 +111,11 @@ fun SavingsProgressScreen( LaunchedEffect(swapResult) { when (val result = swapResult) { + is SavingsSwapResult.Success if transfer.isSparkWithdrawFailed.value -> { + wallet.refreshState() + showSparkFailure(movesChannels = true) + } + is SavingsSwapResult.Success -> { wallet.refreshState() progressState = SavingsProgressState.SUCCESS @@ -90,6 +124,11 @@ fun SavingsProgressScreen( // The hold invoice is paid but the on-chain claim has not landed within the wait // window. The claim is auto-broadcast once the lockup appears, so the transfer is // committed and settling; show that honestly instead of a completed success. + SavingsSwapResult.Pending if transfer.isSparkWithdrawFailed.value -> { + wallet.refreshState() + showSparkFailure(movesChannels = true) + } + SavingsSwapResult.Pending -> { wallet.refreshState() progressState = SavingsProgressState.SETTLING @@ -123,11 +162,12 @@ private suspend fun runChannelClose( onInterrupted: () -> Unit, onGiveUp: () -> Unit, ) { + val closesChannels = transfer.hasChannelsToClose() val channelsFailedToCoopClose = transfer.closeSelectedChannels() if (channelsFailedToCoopClose.isEmpty()) { wallet.refreshState() - delay(5000) + if (closesChannels) delay(5000) onSuccess() return } 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 new file mode 100644 index 0000000000..2cfcd3cbfb --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt @@ -0,0 +1,269 @@ +package to.bitkit.ui.screens.transfer + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.SparkClaimOffer +import to.bitkit.repositories.SparkDepositClaim +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.SecondaryButton +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 + +/** Confirmations Spark waits for before it adds a deposit by itself. */ +private const val MATURE_CONFIRMATIONS = 3 + +/** About how long [MATURE_CONFIRMATIONS] take. */ +private const val MATURE_MINUTES = 30 + +@Composable +fun SettingUpScreen( + viewModel: SparkFundingViewModel, + onContinueClick: () -> Unit = {}, +) { + val app = appViewModel ?: return + val state by viewModel.uiState.collectAsStateWithLifecycle() + + DisposableEffect(Unit) { + app.enabledTransactionSheet(false) + onDispose { + app.enabledTransactionSheet(true) + } + } + + Content( + claim = state.claim, + onPayClick = viewModel::onPayRepriced, + onWaitClick = viewModel::onWait, + onContinueClick = onContinueClick, + ) +} + +@Composable +private fun Content( + claim: SparkDepositClaim, + onPayClick: () -> Unit = {}, + onWaitClick: () -> Unit = {}, + onContinueClick: () -> Unit = {}, +) { + val inProgress = claim != SparkDepositClaim.Claimed + 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) + ) { + VerticalSpacer(12.dp) + if (inProgress) { + Display( + text = stringResource(R.string.lightning__savings_progress__title) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth(), + ) + VerticalSpacer(8.dp) + BodyM( + text = claimText(claim), + color = Colors.White64, + modifier = Modifier + .fillMaxWidth() + .testTag("SettingUpText") + ) + } else { + Display( + text = stringResource(R.string.lightning__transfer_success__title_spending) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth(), + ) + VerticalSpacer(8.dp) + BodyM( + text = stringResource(R.string.lightning__transfer_success__text_spending), + color = Colors.White64, + modifier = Modifier.fillMaxWidth(), + ) + } + VerticalSpacer(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__step_sent), + stringResource(R.string.lightning__setting_up__step_seen), + stringResource(R.string.lightning__setting_up__step_adding), + ) + ProgressSteps( + steps = steps, + activeStepIndex = claim.stepIndex(), + modifier = Modifier.align(alignment = Alignment.CenterHorizontally) + ) + VerticalSpacer(16.dp) + } else { + Image( + painter = painterResource(id = R.drawable.check), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .weight(1f) + .padding(16.dp) + ) + } + + if (claim is SparkDepositClaim.Repriced) { + PrimaryButton( + text = stringResource(R.string.lightning__setting_up__pay) + .replace("{amount}", claim.offer.earlyFeeSats.formatToModernDisplay()), + onClick = onPayClick, + modifier = Modifier.testTag("SettingUpPay") + ) + VerticalSpacer(16.dp) + SecondaryButton( + text = stringResource(R.string.lightning__spark_deposit__wait_button), + onClick = onWaitClick, + modifier = Modifier.testTag("SettingUpWait") + ) + } else { + 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") + ) + } + VerticalSpacer(16.dp) + } + } +} + +@Composable +private fun claimText(claim: SparkDepositClaim): String = when (claim) { + SparkDepositClaim.Looking -> stringResource(R.string.lightning__spark_deposit__looking) + SparkDepositClaim.Adding, SparkDepositClaim.Claimed -> stringResource(R.string.lightning__spark_deposit__claimed) + is SparkDepositClaim.Repriced -> stringResource(R.string.lightning__setting_up__repriced) + .replace("{amount}", claim.offer.earlyFeeSats.formatToModernDisplay()) + + SparkDepositClaim.Unavailable -> stringResource(R.string.lightning__setting_up__unavailable) + SparkDepositClaim.Later -> stringResource(R.string.lightning__spark_deposit__standard) + .replace("{standard}", MATURE_CONFIRMATIONS.toString()) + .replace("{minutes}", MATURE_MINUTES.toString()) + + SparkDepositClaim.NotSeen -> stringResource(R.string.lightning__setting_up__not_seen) +} + +private fun SparkDepositClaim.stepIndex(): Int = when (this) { + SparkDepositClaim.Looking, SparkDepositClaim.Later, SparkDepositClaim.NotSeen -> 1 + SparkDepositClaim.Adding, is SparkDepositClaim.Repriced, SparkDepositClaim.Unavailable -> 2 + SparkDepositClaim.Claimed -> 3 +} + +private val previewRepriced = SparkDepositClaim.Repriced( + SparkClaimOffer.Choice( + txid = "txid", + vout = 0u, + earlyFeeSats = 1_850uL, + earlyConfirmations = 0u, + standardFeeSats = 198uL, + standardConfirmations = 3u, + ) +) + +@Preview(name = "Looking", showSystemUi = true, showBackground = true) +@Composable +private fun PreviewLooking() { + AppThemeSurface { Content(claim = SparkDepositClaim.Looking) } +} + +@Preview(name = "Looking", showSystemUi = true, showBackground = true, device = NEXUS_5) +@Composable +private fun PreviewLookingSmall() { + AppThemeSurface { Content(claim = SparkDepositClaim.Looking) } +} + +@Preview(name = "Adding", showSystemUi = true, showBackground = true) +@Composable +private fun PreviewAdding() { + AppThemeSurface { Content(claim = SparkDepositClaim.Adding) } +} + +@Preview(name = "Repriced", showSystemUi = true, showBackground = true) +@Composable +private fun PreviewRepriced() { + AppThemeSurface { Content(claim = previewRepriced) } +} + +@Preview(name = "Repriced", showSystemUi = true, showBackground = true, device = NEXUS_5) +@Composable +private fun PreviewRepricedSmall() { + AppThemeSurface { Content(claim = previewRepriced) } +} + +@Preview(name = "Unavailable", showSystemUi = true, showBackground = true) +@Composable +private fun PreviewUnavailable() { + AppThemeSurface { Content(claim = SparkDepositClaim.Unavailable) } +} + +@Preview(name = "Later", showSystemUi = true, showBackground = true) +@Composable +private fun PreviewLater() { + AppThemeSurface { Content(claim = SparkDepositClaim.Later) } +} + +@Preview(name = "Not seen", showSystemUi = true, showBackground = true) +@Composable +private fun PreviewNotSeen() { + AppThemeSurface { Content(claim = SparkDepositClaim.NotSeen) } +} + +@Preview(name = "Success", showSystemUi = true, showBackground = true) +@Composable +private fun PreviewSuccess() { + AppThemeSurface { Content(claim = SparkDepositClaim.Claimed) } +} + +@Preview(name = "Success", showSystemUi = true, showBackground = true, device = NEXUS_5) +@Composable +private fun PreviewSuccessSmall() { + AppThemeSurface { Content(claim = SparkDepositClaim.Claimed) } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SparkFundingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SparkFundingViewModel.kt new file mode 100644 index 0000000000..a639272fc7 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SparkFundingViewModel.kt @@ -0,0 +1,454 @@ +package to.bitkit.ui.screens.transfer + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.FeeRates +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import to.bitkit.R +import to.bitkit.ext.getSatsPerVByteFor +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.BITCOIN_SYMBOL +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.Toast +import to.bitkit.models.TransactionSpeed +import to.bitkit.models.formatMoney +import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.safe +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.SparkClaimSpeed +import to.bitkit.repositories.SparkDepositClaim +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.ui.screens.transfer.SparkFundingContract.Quote +import to.bitkit.ui.screens.transfer.SparkFundingContract.SideEffect +import to.bitkit.ui.screens.transfer.SparkFundingContract.UiState +import to.bitkit.ui.screens.wallets.send.HwSendRequest +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger +import javax.inject.Inject +import kotlin.math.roundToLong + +/** + * Transfer to Spending through Spark: sends savings to the Spark deposit address at the speed the user picks, then + * hands the deposit to [SparkRepo] to claim it Instant or Later. + */ +@HiltViewModel +class SparkFundingViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val walletRepo: WalletRepo, + private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, + private val currencyRepo: CurrencyRepo, + private val hwWalletRepo: HwWalletRepo, +) : ViewModel() { + companion object { + private const val TAG = "SparkFundingViewModel" + + /** Share of the most Savings can send that the 25% button picks. */ + private const val QUARTER = 0.25 + + /** Fast, so the deposit confirms soon whichever claim speed the user picks. */ + private val SPEED = TransactionSpeed.Fast + } + + private val _uiState = MutableStateFlow(UiState()) + val uiState = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects = _effects.asSharedFlow() + + private var maxJob: Job? = null + private var quoteJob: Job? = null + + /** The fee rates the shown quote was priced at, so the transfer pays exactly what the screen shows. */ + private var quotedFeeRates: FeeRates? = null + + init { + viewModelScope.launch { + lightningRepo.lightningState.map { it.nodeStatus?.isRunning ?: false }.distinctUntilChanged() + .collect { isRunning -> _uiState.update { it.copy(isNodeRunning = isRunning) } } + } + viewModelScope.launch { + walletRepo.balanceState.map { it.maxSendOnchainSats }.distinctUntilChanged().collectLatest { + refreshMax() + } + } + viewModelScope.launch { + sparkRepo.depositClaims.collect { claims -> + _uiState.update { state -> state.copy(claim = state.txid?.let { claims[it] } ?: state.claim) } + } + } + } + + /** Sends from the hardware wallet [walletId] instead of Savings. */ + fun useHardwareWallet(walletId: String) { + if (_uiState.value.hwWalletId == walletId) return + _uiState.update { it.copy(hwWalletId = walletId, isMaxLoading = true) } + refreshMax() + } + + /** Works out the most Savings can send to Spark after the network fee; shown as available and used by Max. */ + fun refreshMax() { + maxJob?.cancel() + maxJob = viewModelScope.launch { + val max = runSuspendCatching { + val address = sparkRepo.depositAddress().getOrThrow() + val feeRates = lightningRepo.getFeeRates().getOrThrow() + _uiState.value.hwWalletId?.let { walletId -> + return@runSuspendCatching hwWalletRepo.maxSpendableFunding( + walletId = walletId, + address = address, + satsPerVByte = feeRates.satsPerVByte(), + ).getOrThrow().toLong() + } + val spendable = spendableSats() + if (spendable == 0uL) return@runSuspendCatching 0L + val fee = lightningRepo.estimateSendAllFee(address = address, speed = SPEED, feeRates = feeRates) + .getOrThrow() + (spendable.safe() - fee.safe()).toLong() + }.getOrElse { + Logger.warn("Failed to estimate the most Spark funding can send", it, context = TAG) + if (_uiState.value.hwWalletId != null) 0L else walletRepo.balanceState.value.maxSendOnchainSats.toLong() + } + _uiState.update { it.copy(maxSats = max, isMaxLoading = false) } + } + } + + fun onAmountChange(sats: Long) { + _uiState.update { it.copy(amountSats = sats, isMax = sats > 0 && sats == it.maxSats) } + } + + fun quarterSats(): Long = _uiState.value.maxSats.let { max -> minOf((max * QUARTER).roundToLong(), max) } + + fun onMaxExceeded() { + toast( + title = R.string.lightning__spending_amount__error_max__title, + description = context.getString(R.string.lightning__spending_amount__error_max__description) + .replace("{amount}", _uiState.value.maxSats.formatToModernDisplay()), + ) + } + + /** Prices the transfer and moves on to the confirm screen when Savings can cover it and Spark can add it. */ + fun onAmountContinue() { + if (_uiState.value.isLoading) return + _uiState.update { it.copy(isLoading = true) } + viewModelScope.launch { + val quote = quote().getOrElse { + Logger.error("Failed to price Spark funding", it, context = TAG) + toastQuoteError() + _uiState.update { state -> state.copy(isLoading = false) } + return@launch + } + _uiState.update { it.copy(isLoading = false) } + when { + !quote.isCovered -> toast( + title = R.string.lightning__spending_amount__error_balance__title, + description = context.getString(R.string.lightning__spending_amount__error_balance__description), + ) + + quote.amountSats <= quote.instantFeeSats -> toast( + title = R.string.lightning__spending_amount__error_min__title, + description = context.getString(R.string.lightning__spending_amount__error_min__description) + .replace("{amount}", quote.instantFeeSats.formatToModernDisplay()), + ) + + else -> { + showQuote(quote) + _effects.emit(SideEffect.AmountReady) + } + } + } + } + + /** Re-prices the confirm screen, so it never shows a stale quote. */ + fun onConfirmAppear() = requote() + + fun onSpeedChange(speed: SparkClaimSpeed) { + if (_uiState.value.isSending) return + _uiState.update { it.copy(speed = speed) } + requote() + } + + private fun requote() { + quoteJob?.cancel() + quoteJob = viewModelScope.launch { + _uiState.update { it.copy(isQuoting = true) } + quote() + .onSuccess { showQuote(it) } + .onFailure { + Logger.error("Failed to re-price Spark funding", it, context = TAG) + toastQuoteError() + } + _uiState.update { it.copy(isQuoting = false) } + } + } + + /** Prices again at swipe time, and asks for another swipe when the transfer got worse since it was shown. */ + fun onConfirm() { + val shown = _uiState.value + val shownQuote = shown.quote ?: return + if (shown.isSending || shown.isQuoting) return + quoteJob?.cancel() + _uiState.update { it.copy(isSending = true) } + viewModelScope.launch { + val quote = quote().getOrElse { + Logger.error("Failed to re-price Spark funding at swipe", it, context = TAG) + toastQuoteError() + _uiState.update { state -> state.copy(isSending = false) } + return@launch + } + showQuote(quote) + if (!quote.isCovered) { + Logger.info("Refused transfer that savings no longer cover", context = TAG) + toast( + title = R.string.lightning__spending_amount__error_balance__title, + description = context.getString(R.string.lightning__spending_amount__error_balance__description), + ) + _uiState.update { it.copy(isSending = false) } + return@launch + } + val totalRise = quote.totalSats - shownQuote.totalSats + val spendingDrop = shownQuote.toSpendingSats(shown.speed) - quote.toSpendingSats(shown.speed) + if (totalRise > 0 || spendingDrop > 0) { + Logger.info("Asking for another swipe, fees rose since shown", context = TAG) + toast( + title = R.string.lightning__spending_confirm__fees_changed_title, + description = context.getString(R.string.lightning__spending_confirm__fees_changed_network) + .replace("{amount}", formatPrimaryAmount(maxOf(totalRise, spendingDrop))), + type = Toast.ToastType.INFO, + ) + _uiState.update { it.copy(isSending = false) } + return@launch + } + val walletId = shown.hwWalletId + if (walletId == null) { + send(quote) + return@launch + } + _uiState.update { it.copy(isSending = false) } + _effects.emit( + SideEffect.SignHw( + HwSendRequest( + walletId = walletId, + address = quote.address, + amountSats = quote.amountSats.toULong(), + satsPerVByte = quotedFeeRates?.satsPerVByte() ?: return@launch, + tags = emptyList(), + isTransfer = true, + ) + ) + ) + } + } + + /** Hands the deposit the hardware wallet broadcast in [txid] to Spark, as [send] does for Savings. */ + fun onHwBroadcast(txid: String, amountSats: ULong) { + val state = _uiState.value + val quote = state.quote ?: return + viewModelScope.launch { + withContext(NonCancellable) { + sparkRepo.onDepositBroadcast(txid, amountSats) + sparkRepo.followDepositClaim(txid, state.speed, quote.instantFeeSats.toULong()) + } + Logger.info("Funded Spark from a hardware wallet with '$amountSats' sats in '$txid'", context = TAG) + _uiState.update { it.copy(txid = txid, claim = sparkRepo.depositClaims.value[txid] ?: it.claim) } + _effects.emit(SideEffect.Sent) + } + } + + private suspend fun send(quote: Quote) { + val state = _uiState.value + runSuspendCatching { + val outcome = lightningRepo.sendOnChain( + address = quote.address, + sats = quote.amountSats.toULong(), + speed = SPEED, + feeRates = quotedFeeRates, + isTransfer = true, + isMaxAmount = state.isMax, + ).getOrThrow() + check(outcome is OnchainSendOutcome.Accepted) { "Spark funding was not accepted: '$outcome'" } + withContext(NonCancellable) { + runSuspendCatching { + lightningRepo.completeAcceptedTransferFollowup(orderId = null, txid = outcome.txid) + }.onFailure { Logger.warn("Failed to finish Spark funding locally", it, context = TAG) } + sparkRepo.onDepositBroadcast(outcome.txid, outcome.amountSats ?: quote.amountSats.toULong()) + sparkRepo.followDepositClaim(outcome.txid, state.speed, quote.instantFeeSats.toULong()) + } + Logger.info("Funded Spark with '${quote.amountSats}' sats in '${outcome.txid}'", context = TAG) + _uiState.update { + it.copy(txid = outcome.txid, claim = sparkRepo.depositClaims.value[outcome.txid] ?: it.claim) + } + _effects.emit(SideEffect.Sent) + }.onFailure { + Logger.error("Failed to fund Spark", it, context = TAG) + toast( + title = R.string.lightning__close_error, + description = context.getString(R.string.wallet__send_error_create_tx), + ) + } + _uiState.update { it.copy(isSending = false) } + } + + fun onPayRepriced() { + val txid = _uiState.value.txid ?: return + sparkRepo.payRepricedClaim(txid) + } + + fun onWait() { + val txid = _uiState.value.txid ?: return + sparkRepo.waitForMatureClaim(txid) + } + + private suspend fun quote(): Result = runSuspendCatching { + val state = _uiState.value + val address = sparkRepo.depositAddress().getOrThrow() + val feeRates = lightningRepo.getFeeRates().getOrThrow() + state.hwWalletId?.let { return@runSuspendCatching hwQuote(it, address, feeRates) } + val spendable = spendableSats() + val networkFee = if (state.isMax) { + lightningRepo.estimateSendAllFee(address = address, speed = SPEED, feeRates = feeRates).getOrThrow() + } else { + lightningRepo.calculateTotalFee( + amountSats = state.amountSats.toULong(), + address = address, + speed = SPEED, + feeRates = feeRates, + ).getOrThrow() + } + val amount = if (state.isMax) (spendable.safe() - networkFee.safe()) else state.amountSats.toULong() + val instantFee = sparkRepo.estimateDepositClaimFeeSats(amount).getOrThrow() + val laterFee = sparkRepo.estimateMatureClaimFeeSats().getOrThrow() + quotedFeeRates = feeRates + Quote( + address = address, + amountSats = amount.toLong(), + networkFeeSats = networkFee.toLong(), + instantFeeSats = instantFee.toLong(), + laterFeeSats = laterFee.toLong(), + spendableSats = spendable.toLong(), + ) + } + + private suspend fun hwQuote(walletId: String, address: String, feeRates: FeeRates): Quote { + val amount = _uiState.value.amountSats.toULong() + val satsPerVByte = feeRates.satsPerVByte() + val max = hwWalletRepo.maxSpendableFunding(walletId, address, satsPerVByte).getOrThrow() + val networkFee = hwWalletRepo.estimateFundingMiningFee(walletId, address, amount, satsPerVByte).getOrThrow() + val instantFee = sparkRepo.estimateDepositClaimFeeSats(amount).getOrThrow() + val laterFee = sparkRepo.estimateMatureClaimFeeSats().getOrThrow() + quotedFeeRates = feeRates + return Quote( + address = address, + amountSats = amount.toLong(), + networkFeeSats = networkFee.toLong(), + instantFeeSats = instantFee.toLong(), + laterFeeSats = laterFee.toLong(), + // Max is what the device can send once its own fee is paid, so it covers any amount up to it. + spendableSats = (max.safe() + networkFee.safe()).toLong(), + ) + } + + private fun FeeRates.satsPerVByte(): ULong = getSatsPerVByteFor(SPEED).toULong() + + private fun showQuote(quote: Quote) { + _uiState.update { it.copy(quote = quote, amountSats = quote.amountSats) } + } + + private suspend fun spendableSats(): ULong = + lightningRepo.getBalancesAsync().getOrThrow().spendableOnchainBalanceSats + + private fun toastQuoteError() = toast( + title = R.string.lightning__spending_confirm__quote_error_title, + description = context.getString(R.string.lightning__spending_confirm__quote_error_text), + ) + + private fun toast(title: Int, description: String, type: Toast.ToastType = Toast.ToastType.WARNING) { + viewModelScope.launch { + ToastEventBus.send( + type = type, + title = context.getString(title), + description = description, + visibilityTime = Toast.VISIBILITY_TIME_SHORT, + ) + } + } + + private fun formatPrimaryAmount(sats: Long): String { + val state = currencyRepo.currencyState.value + val fiat = currencyRepo.convertSatsToFiat(sats).getOrNull() + if (state.primaryDisplay == PrimaryDisplay.FIAT && fiat != null) return fiat.formattedWithSymbol() + return "$BITCOIN_SYMBOL ${sats.formatMoney(state.displayUnit)}" + } +} + +interface SparkFundingContract { + @Immutable + data class UiState( + val isNodeRunning: Boolean = false, + /** The hardware wallet the transfer sends from, or null for Savings. */ + val hwWalletId: String? = null, + val isLoading: Boolean = false, + val isMaxLoading: Boolean = true, + val maxSats: Long = 0, + val amountSats: Long = 0, + val isMax: Boolean = false, + val speed: SparkClaimSpeed = SparkClaimSpeed.Instant, + val quote: Quote? = null, + val isQuoting: Boolean = false, + val isSending: Boolean = false, + val txid: String? = null, + val claim: SparkDepositClaim = SparkDepositClaim.Looking, + ) + + /** What the transfer costs, priced together so the confirm screen's tiles always agree. */ + @Immutable + data class Quote( + val address: String, + val amountSats: Long, + val networkFeeSats: Long, + /** The most an Instant claim may cost: the ceiling the user accepts. */ + val instantFeeSats: Long, + /** What the claim at 3 confirmations costs at the current half-hour fee rate. */ + val laterFeeSats: Long, + val spendableSats: Long, + ) { + val totalSats: Long get() = amountSats + networkFeeSats + + /** Whether Savings can pay the amount and its network fee. */ + val isCovered: Boolean get() = totalSats <= spendableSats + + fun claimFeeSats(speed: SparkClaimSpeed): Long = when (speed) { + SparkClaimSpeed.Instant -> instantFeeSats + SparkClaimSpeed.Later -> laterFeeSats + } + + fun toSpendingSats(speed: SparkClaimSpeed): Long = (amountSats - claimFeeSats(speed)).coerceAtLeast(0) + } + + sealed interface SideEffect { + data object AmountReady : SideEffect + data object Sent : SideEffect + + /** Priced and checked; the hardware wallet signs [request] next. */ + data class SignHw(val request: HwSendRequest) : SideEffect + } +} 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 new file mode 100644 index 0000000000..dde1890aee --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -0,0 +1,327 @@ +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.heightIn +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.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.repositories.CurrencyState +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.components.ConnectionIssuesView +import to.bitkit.ui.components.Display +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.screens.transfer.SparkFundingContract.SideEffect +import to.bitkit.ui.screens.transfer.SparkFundingContract.UiState +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.previewAmountInputViewModel + +@Suppress("ViewModelForwarding") +@Composable +fun SpendingAmountScreen( + viewModel: SparkFundingViewModel, + isOffline: Boolean, + onBackClick: () -> Unit = {}, + onQuoteReady: () -> Unit = {}, + currencies: CurrencyState = LocalCurrencies.current, + amountInputViewModel: AmountInputViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + val currentMaxSats by rememberUpdatedState(uiState.maxSats) + val currentCurrencies by rememberUpdatedState(currencies) + + LaunchedEffect(isOffline) { + viewModel.refreshMax() + } + + LaunchedEffect(amountUiState.sats) { + viewModel.onAmountChange(amountUiState.sats) + } + + LaunchedEffect(viewModel, onQuoteReady) { + viewModel.effects.collect { + if (it == SideEffect.AmountReady) onQuoteReady() + } + } + + LaunchedEffect(Unit) { + amountInputViewModel.effect.collect { + when (it) { + AmountInputEffect.MaxExceeded -> { + amountInputViewModel.setSats(currentMaxSats, currentCurrencies) + viewModel.onMaxExceeded() + } + } + } + } + + Box { + Content( + uiState = uiState, + amountInputViewModel = amountInputViewModel, + currencies = currencies, + onBackClick = onBackClick, + onClickQuarter = { amountInputViewModel.setSats(viewModel.quarterSats(), currencies) }, + onClickMaxAmount = { amountInputViewModel.setSats(uiState.maxSats, currencies) }, + onConfirmAmount = viewModel::onAmountContinue, + ) + 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( + uiState: UiState, + 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 (uiState.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: UiState, + amountInputViewModel: AmountInputViewModel, + currencies: CurrencyState, + onClickQuarter: () -> Unit, + onClickMaxAmount: () -> Unit, + onConfirmAmount: () -> Unit, +) { + LaunchedEffect(uiState.maxSats, uiState.isMaxLoading) { + if (!uiState.isMaxLoading) amountInputViewModel.setMaxAmount(uiState.maxSats) + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .testTag(if (uiState.hwWalletId != null) "HardwareTransferAmount" else "SpendingAmount") + ) { + val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + val isHardware = uiState.hwWalletId != null + val tag = if (isHardware) "HardwareTransferAmount" else "SpendingAmount" + val isReady = !uiState.isLoading && !uiState.isMaxLoading + val hasFunds = uiState.maxSats > 0 + + 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("${tag}NumberField") + ) + + // Keys shrink on short screens, so Continue stays visible below the pad. + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.testTag( + if (isHardware) "HardwareTransferAmountNumberPad" else "SendAmountNumberPad" + ) + ) { + Column { + Text13Up( + text = stringResource(R.string.wallet__send_available), + color = Colors.White64, + modifier = Modifier.testTag("${tag}Available") + ) + VerticalSpacer(8.dp) + MoneySSB( + sats = uiState.maxSats, + showSymbol = true, + modifier = Modifier.testTag("${tag}Unit") + ) + } + FillWidth() + UnitButton( + color = Colors.Purple, + onClick = { amountInputViewModel.switchUnit(currencies) }, + modifier = Modifier.testTag( + if (isHardware) "HardwareTransferAmountUnitButton" else "SpendingNumberPadUnit" + ) + ) + NumberPadActionButton( + text = stringResource(R.string.lightning__spending_amount__quarter), + color = Colors.Purple, + enabled = isReady && hasFunds, + onClick = onClickQuarter, + modifier = Modifier.testTag("${tag}Quarter") + ) + NumberPadActionButton( + text = stringResource(R.string.common__max), + color = Colors.Purple, + enabled = isReady && hasFunds, + onClick = onClickMaxAmount, + modifier = Modifier.testTag("${tag}Max") + ) + } + + HorizontalDivider(modifier = Modifier.padding(top = 12.dp)) + + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, + enabled = isReady && hasFunds, + modifier = Modifier + .weight(1f) + .heightIn(max = NUMBER_PAD_MAX_HEIGHT) + ) + } + + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onConfirmAmount, + enabled = isReady && amountUiState.sats > 0 && amountUiState.sats <= uiState.maxSats, + isLoading = uiState.isLoading, + modifier = Modifier.testTag("${tag}Continue") + ) + + VerticalSpacer(16.dp) + } +} + +/** The number pad's height at its ideal key size. */ +private val NUMBER_PAD_MAX_HEIGHT = 300.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 = UiState(isNodeRunning = true, isMaxLoading = false, maxSats = 158_234), + amountInputViewModel = previewAmountInputViewModel(), + currencies = CurrencyState(), + onBackClick = {}, + onClickQuarter = {}, + onClickMaxAmount = {}, + onConfirmAmount = {}, + ) + } +} + +@Preview(showSystemUi = true, name = "No funds") +@Composable +private fun PreviewNoFunds() { + AppThemeSurface { + Content( + uiState = UiState(isNodeRunning = true, isMaxLoading = false, maxSats = 0), + amountInputViewModel = previewAmountInputViewModel(), + currencies = CurrencyState(), + onBackClick = {}, + onClickQuarter = {}, + onClickMaxAmount = {}, + onConfirmAmount = {}, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewInitializing() { + AppThemeSurface { + Content( + uiState = UiState(), + 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 new file mode 100644 index 0000000000..a5d2e52b50 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -0,0 +1,367 @@ +package to.bitkit.ui.screens.transfer + +import androidx.activity.compose.BackHandler +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.Color +import androidx.compose.ui.graphics.graphicsLayer +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.repositories.SparkClaimSpeed +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.ConnectionIssuesView +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FeeInfo +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.HorizontalSpacer +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SwipeToConfirm +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.SparkFundingContract.Quote +import to.bitkit.ui.screens.transfer.SparkFundingContract.SideEffect +import to.bitkit.ui.screens.transfer.SparkFundingContract.UiState +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 + +private const val SWIPE_ROTATION_DEGREES = 14f + +@Composable +fun SpendingConfirmScreen( + viewModel: SparkFundingViewModel, + isOffline: Boolean, + onBackClick: () -> Unit = {}, + onLearnMoreClick: () -> Unit = {}, + onSent: () -> Unit = {}, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + BackHandler(enabled = state.isSending) {} + + LaunchedEffect(Unit) { + viewModel.onConfirmAppear() + } + + LaunchedEffect(viewModel, onSent) { + viewModel.effects.collect { + if (it == SideEffect.Sent) onSent() + } + } + + Box { + Content( + state = state, + onBackClick = { if (!state.isSending) onBackClick() }, + onLearnMoreClick = { if (!state.isSending) onLearnMoreClick() }, + onSpeedChange = viewModel::onSpeedChange, + onConfirm = viewModel::onConfirm, + ) + AnimatedVisibility( + visible = isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + ConnectionIssuesView( + titleText = stringResource(R.string.lightning__transfer__nav_title), + modifier = Modifier.statusBarsPadding() + ) + } + } +} + +@Composable +private fun Content( + state: UiState, + onBackClick: () -> Unit = {}, + onLearnMoreClick: () -> Unit = {}, + onSpeedChange: (SparkClaimSpeed) -> Unit = {}, + onConfirm: () -> Unit = {}, +) { + val swipeProgress = remember { mutableFloatStateOf(0f) } + val quote = state.quote + + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__transfer__nav_title), + onBackClick = onBackClick, + actions = { if (!state.isSending) DrawerNavIcon() }, + ) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .testTag("SpendingConfirm") + ) { + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + ) { + VerticalSpacer(32.dp) + Display( + stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple) + ) + VerticalSpacer(8.dp) + + SpendingFeeGrid(quote = quote, speed = state.speed) + + VerticalSpacer(16.dp) + + SpendingSpeedRows(quote = quote, speed = state.speed, onSpeedChange = onSpeedChange) + + VerticalSpacer(16.dp) + + PrimaryButton( + text = stringResource(R.string.common__learn_more), + size = ButtonSize.Small, + fullWidth = false, + onClick = onLearnMoreClick, + modifier = Modifier.testTag("SpendingConfirmMore") + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Image( + painter = painterResource(id = R.drawable.coin_stack_x), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(horizontal = 44.dp, vertical = 8.dp) + .graphicsLayer { rotationZ = swipeProgress.floatValue * SWIPE_ROTATION_DEGREES } + ) + } + } + } + + val canConfirm = quote != null && quote.toSpendingSats(state.speed) > 0 && !state.isQuoting + SwipeToConfirm( + text = stringResource(R.string.lightning__transfer__swipe), + loading = state.isSending || state.isQuoting || quote == null, + confirmed = state.isSending, + enabled = canConfirm, + color = Colors.Purple, + progress = swipeProgress, + onConfirm = { if (canConfirm) onConfirm() }, + ) + VerticalSpacer(16.dp) + } + } +} + +/** The four fee tiles of a transfer to spending, at the selected claim [speed]. */ +@Composable +internal fun SpendingFeeGrid(quote: Quote?, speed: SparkClaimSpeed, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + val claimPrefix = when (speed) { + SparkClaimSpeed.Instant -> stringResource(R.string.lightning__spending_confirm__up_to) + SparkClaimSpeed.Later -> stringResource(R.string.lightning__spending_confirm__about) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__network_fee), + amount = quote?.networkFeeSats ?: 0, + modifier = Modifier.testTag("SpendingConfirmNetworkFee") + ) + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__spending_fee), + amount = quote?.claimFeeSats(speed) ?: 0, + prefix = claimPrefix, + modifier = Modifier.testTag("SpendingConfirmSpendingFee") + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__amount), + amount = quote?.toSpendingSats(speed) ?: 0, + prefix = claimPrefix.takeIf { speed == SparkClaimSpeed.Later }, + modifier = Modifier.testTag("SpendingConfirmAmount") + ) + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__total), + amount = quote?.totalSats ?: 0, + modifier = Modifier.testTag("SpendingConfirmTotal") + ) + } + } +} + +/** The Instant and Later rows a transfer to spending picks its claim speed with. */ +@Composable +internal fun SpendingSpeedRows( + quote: Quote?, + speed: SparkClaimSpeed, + onSpeedChange: (SparkClaimSpeed) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + SpeedItem( + title = stringResource(R.string.fee__instant__title), + description = stringResource(R.string.lightning__spending_confirm__instant_text), + feePrefix = stringResource(R.string.lightning__spending_confirm__up_to), + feeSats = quote?.instantFeeSats, + icon = R.drawable.ic_speed_fast, + iconColor = Colors.Purple, + isSelected = speed == SparkClaimSpeed.Instant, + onClick = { onSpeedChange(SparkClaimSpeed.Instant) }, + modifier = Modifier.testTag("SpendingConfirmInstant") + ) + SpeedItem( + title = stringResource(R.string.common__later), + description = stringResource(R.string.lightning__spending_confirm__later_text), + feePrefix = stringResource(R.string.lightning__spending_confirm__about), + feeSats = quote?.laterFeeSats, + icon = R.drawable.ic_speed_normal, + iconColor = Colors.Purple, + isSelected = speed == SparkClaimSpeed.Later, + onClick = { onSpeedChange(SparkClaimSpeed.Later) }, + modifier = Modifier.testTag("SpendingConfirmLater") + ) + HorizontalDivider() + } +} + +@Composable +private fun SpeedItem( + title: String, + description: String, + feePrefix: String, + feeSats: Long?, + @DrawableRes icon: Int, + iconColor: Color, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clickableAlpha(onClick = onClick) + .then(if (isSelected) Modifier.background(Colors.White06) else Modifier) + ) { + HorizontalDivider() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 16.dp) + .height(72.dp) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(32.dp), + ) + HorizontalSpacer(16.dp) + Column { + BodyMSB(title, color = MaterialTheme.colorScheme.primary) + BodySSB(description, color = MaterialTheme.colorScheme.secondary) + } + FillWidth() + if (feeSats != null) { + Row { + BodySSB("$feePrefix ", color = Colors.White64) + MoneySSB(sats = feeSats, showSymbol = true) + } + } + } + } +} + +private val previewQuote = Quote( + address = "bcrt1q", + amountSats = 50_000, + networkFeeSats = 577, + instantFeeSats = 1_350, + laterFeeSats = 198, + spendableSats = 158_234, +) + +@Preview(showSystemUi = true, showBackground = true, name = "Instant") +@Composable +private fun PreviewInstant() { + AppThemeSurface { + Content(state = UiState(amountSats = 50_000, quote = previewQuote)) + } +} + +@Preview(showSystemUi = true, showBackground = true, name = "Later") +@Composable +private fun PreviewLater() { + AppThemeSurface { + Content(state = UiState(amountSats = 50_000, quote = previewQuote, speed = SparkClaimSpeed.Later)) + } +} + +@Preview(showSystemUi = true, showBackground = true, name = "Loading") +@Composable +private fun PreviewLoading() { + AppThemeSurface { + Content(state = UiState(amountSats = 50_000, isQuoting = true)) + } +} + +@Preview(showSystemUi = true, showBackground = true, name = "Small screen", device = NEXUS_5) +@Composable +private fun PreviewSmall() { + AppThemeSurface { + Content(state = UiState(amountSats = 50_000, quote = previewQuote)) + } +} 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 new file mode 100644 index 0000000000..7a94031b47 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingIntroScreen.kt @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000000..45192970ba --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferIntroScreen.kt @@ -0,0 +1,85 @@ +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/TransferLearnMoreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferLearnMoreScreen.kt new file mode 100644 index 0000000000..bba6afc6fe --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferLearnMoreScreen.kt @@ -0,0 +1,112 @@ +package to.bitkit.ui.screens.transfer + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +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.getValue +import androidx.compose.ui.Modifier +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.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.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 + +/** Explains Instant and Later, and shows the spending balance this transfer leads to. */ +@Composable +fun TransferLearnMoreScreen( + viewModel: SparkFundingViewModel, + spendingBalanceSats: Long, + onContinueClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val toSpending = state.quote?.toSpendingSats(state.speed) ?: 0 + + Content( + balanceSats = spendingBalanceSats + toSpending, + onBackClick = onBackClick, + onContinueClick = onContinueClick, + ) +} + +@Composable +private fun Content( + balanceSats: Long, + onBackClick: () -> Unit = {}, + onContinueClick: () -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__transfer__nav_title), + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.lightning__spending_learn_more__title) + .withAccent(accentColor = Colors.Purple) + ) + VerticalSpacer(8.dp) + BodyM(text = stringResource(R.string.lightning__spending_learn_more__text), color = Colors.White64) + VerticalSpacer(16.dp) + BodyM(text = stringResource(R.string.lightning__spark_trust), color = Colors.White64) + VerticalSpacer(32.dp) + } + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + BodyMB(text = stringResource(R.string.lightning__spending_label)) + VerticalSpacer(16.dp) + + LightningChannel( + capacity = balanceSats, + localBalance = balanceSats, + remoteBalance = 0L, + status = ChannelStatusUi.OPEN, + showLabels = true, + showReceiving = false, + modifier = Modifier.testTag("TransferLearnMoreBalance") + ) + + VerticalSpacer(32.dp) + PrimaryButton( + text = stringResource(R.string.common__understood), + onClick = onContinueClick, + modifier = Modifier.testTag("LiquidityContinue") + ) + VerticalSpacer(16.dp) + } + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, device = NEXUS_5, name = "Small") +@Composable +private fun Preview() { + AppThemeSurface { + Content(balanceSats = 48_650L) + } +} 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 new file mode 100644 index 0000000000..d8702be5b4 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt @@ -0,0 +1,270 @@ +package to.bitkit.ui.screens.transfer.hardware + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.models.HwWalletVendor +import to.bitkit.repositories.SparkClaimSpeed +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.PrimaryButton +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.SparkFundingContract.Quote +import to.bitkit.ui.screens.transfer.SparkFundingContract.SideEffect +import to.bitkit.ui.screens.transfer.SparkFundingContract.UiState +import to.bitkit.ui.screens.transfer.SparkFundingViewModel +import to.bitkit.ui.screens.transfer.SpendingFeeGrid +import to.bitkit.ui.screens.transfer.SpendingSpeedRows +import to.bitkit.ui.screens.wallets.send.HwSendRequest +import to.bitkit.ui.screens.wallets.send.HwSendViewModel +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +@Composable +fun SpendingHwSignScreen( + walletId: String, + viewModel: SparkFundingViewModel, + onBackClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onSent: () -> Unit, + hwSendViewModel: HwSendViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val hwState by hwSendViewModel.uiState.collectAsStateWithLifecycle() + val wallets by hwSendViewModel.wallets.collectAsStateWithLifecycle() + val vendor = remember(wallets, walletId) { + wallets.firstOrNull { it.id == walletId }?.vendor ?: HwWalletVendor.TREZOR + } + var request by remember { mutableStateOf(null) } + val isBusy = state.isSending || hwState.isSigning + + BackHandler(enabled = !hwState.canLeave || state.isSending) {} + + LaunchedEffect(walletId) { + hwSendViewModel.warmUp(walletId) + viewModel.onConfirmAppear() + } + + LaunchedEffect(viewModel, hwSendViewModel) { + viewModel.effects.collect { + when (it) { + is SideEffect.SignHw -> { + request = it.request + hwSendViewModel.signAndBroadcast(it.request) + } + + SideEffect.Sent -> onSent() + SideEffect.AmountReady -> Unit + } + } + } + + LaunchedEffect(hwSendViewModel) { + hwSendViewModel.results.collect { + viewModel.onHwBroadcast(it.txId, it.amountSats) + hwSendViewModel.completeBroadcast() + } + } + + DisposableEffect(hwSendViewModel) { + onDispose { hwSendViewModel.cancel() } + } + + Content( + state = state, + vendor = vendor, + isSigning = isBusy, + hasPendingBroadcast = hwState.hasPendingBroadcast, + onBackClick = { if (hwState.canLeave && !state.isSending) onBackClick() }, + onLearnMoreClick = { if (!isBusy) onLearnMoreClick() }, + onSpeedChange = { if (!isBusy) viewModel.onSpeedChange(it) }, + onOpenConnect = { + // A pending broadcast resumes only for the exact request it was signed for. + val pending = request?.takeIf { hwState.hasPendingBroadcast } + if (pending != null) hwSendViewModel.signAndBroadcast(pending) else viewModel.onConfirm() + }, + ) + + if (hwState.isPassphraseRequired) { + HwPassphrasePromptSheet( + isVerifying = hwState.isVerifyingPassphrase, + onSubmit = { passphrase -> + hwSendViewModel.submitPassphrase( + walletId = walletId, + passphrase = passphrase, + onReady = { request?.let { hwSendViewModel.signAndBroadcast(it) } }, + ) + }, + onDismiss = hwSendViewModel::dismissPassphrase, + ) + } +} + +@Composable +private fun Content( + state: UiState, + vendor: HwWalletVendor = HwWalletVendor.TREZOR, + isSigning: Boolean = false, + hasPendingBroadcast: Boolean = false, + onBackClick: () -> Unit = {}, + onLearnMoreClick: () -> Unit = {}, + onSpeedChange: (SparkClaimSpeed) -> Unit = {}, + onOpenConnect: () -> Unit = {}, +) { + val quote = state.quote + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__transfer__nav_title), + onBackClick = onBackClick, + actions = { if (!isSigning) DrawerNavIcon() }, + ) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .testTag("HardwareTransferSign") + ) { + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + ) { + 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(8.dp) + + SpendingFeeGrid(quote = quote, speed = state.speed) + + VerticalSpacer(16.dp) + + SpendingSpeedRows(quote = quote, speed = state.speed, onSpeedChange = onSpeedChange) + + VerticalSpacer(16.dp) + + PrimaryButton( + text = stringResource(R.string.common__learn_more), + size = ButtonSize.Small, + fullWidth = false, + enabled = !isSigning && !hasPendingBroadcast, + onClick = onLearnMoreClick, + modifier = Modifier.testTag("HardwareTransferSignLearnMore") + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Image( + painter = painterResource(id = vendor.illustrationRes()), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + } + + val canSign = quote != null && quote.toSpendingSats(state.speed) > 0 && !state.isQuoting + PrimaryButton( + text = stringResource( + if (hasPendingBroadcast) R.string.common__retry else R.string.lightning__transfer_hw__open_connect + ), + onClick = onOpenConnect, + enabled = canSign && !isSigning, + isLoading = isSigning || state.isQuoting, + modifier = Modifier.testTag("HardwareTransferOpenTrezorConnect") + ) + VerticalSpacer(16.dp) + } + } +} + +private val previewQuote = Quote( + address = "bcrt1q", + amountSats = 50_000, + networkFeeSats = 1_141, + instantFeeSats = 1_350, + laterFeeSats = 198, + spendableSats = 120_000, +) + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content(state = UiState(quote = previewQuote, hwWalletId = "trezor")) + } +} + +@Preview(showSystemUi = true, device = NEXUS_5, name = "Small") +@Composable +private fun PreviewSmall() { + AppThemeSurface { + Content(state = UiState(quote = previewQuote, hwWalletId = "trezor")) + } +} + +@Preview(showSystemUi = true, name = "Signing") +@Composable +private fun PreviewSigning() { + AppThemeSurface { + Content(state = UiState(quote = previewQuote, hwWalletId = "trezor"), isSigning = true) + } +} + +@Preview(showSystemUi = true, name = "Pending broadcast") +@Composable +private fun PreviewPendingBroadcast() { + AppThemeSurface { + Content(state = UiState(quote = previewQuote, hwWalletId = "trezor"), 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 new file mode 100644 index 0000000000..5ef98966a4 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt @@ -0,0 +1,111 @@ +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.SparkFundingContract.Quote +import to.bitkit.ui.screens.transfer.SparkFundingContract.UiState +import to.bitkit.ui.screens.transfer.SparkFundingViewModel +import to.bitkit.ui.screens.transfer.SpendingFeeGrid +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import kotlin.time.Duration.Companion.seconds + +/** How long the signed confirmation shows before the transfer's progress takes over. */ +private val SIGNED_AUTO_NAV_DELAY = 1.seconds + +@Composable +fun SpendingHwSignedScreen( + viewModel: SparkFundingViewModel, + onContinue: () -> Unit, + onCloseClick: () -> Unit, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + delay(SIGNED_AUTO_NAV_DELAY) + onContinue() + } + + Content( + state = state, + onBackClick = onCloseClick, + ) +} + +@Composable +private fun Content( + state: UiState, + 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) + + SpendingFeeGrid(quote = state.quote, speed = state.speed) + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + state = UiState( + quote = Quote( + address = "bcrt1q", + amountSats = 50_000, + networkFeeSats = 1_141, + instantFeeSats = 1_350, + laterFeeSats = 198, + spendableSats = 120_000, + ), + ), + ) + } +} 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 d398093032..5f23a8830e 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 @@ -166,6 +166,8 @@ 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 @@ -237,6 +239,8 @@ fun HomeScreen( ) { val context = LocalContext.current val resources = LocalResources.current + val hasSeenTransferIntro by settingsViewModel.hasSeenTransferIntro.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() val profileNavigationScope = rememberCoroutineScope() @@ -312,7 +316,11 @@ fun HomeScreen( appViewModel.showSheet(Sheet.Hardware()) } - Suggestion.LIGHTNING -> appViewModel.showSheet(Sheet.TransferUnavailable) + Suggestion.LIGHTNING -> when { + !sparkState.isReady -> appViewModel.showSheet(Sheet.TransferUnavailable) + !hasSeenTransferIntro -> rootNavController.navigateToTransferIntro() + else -> rootNavController.navigateToTransferFunding() + } Suggestion.BACK_UP -> { appViewModel.showSheet(Sheet.Backup(BackupRoute.Intro)) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 2f2a40b2ad..169ae8b03a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -69,16 +69,17 @@ fun SpendingWalletScreen( onTransferFromSavingsClick: () -> Unit, onBackClick: () -> Unit, balances: BalanceState = LocalBalances.current, + hasSparkBalance: Boolean = false, ) { val showEmptyState by remember(balances.totalLightningSats, lightningActivities.size) { val hasLnFunds = balances.totalLightningSats == 0uL val hasActivity = lightningActivities.isNotEmpty() mutableStateOf(hasLnFunds && !hasActivity) } - val canTransfer by remember(balances.totalLightningSats, channels.size) { + val canTransfer by remember(balances.totalLightningSats, channels.size, hasSparkBalance) { val hasLnBalance = balances.totalLightningSats > 0uL val hasChannels = channels.isNotEmpty() - mutableStateOf(hasLnBalance && hasChannels) + mutableStateOf(hasLnBalance && (hasChannels || hasSparkBalance)) } val canTransferFromSavings by remember(showEmptyState, balances.totalOnchainSats) { mutableStateOf(showEmptyState && balances.totalOnchainSats > 0uL) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt index 273465d5a0..2131aec56b 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 @@ -38,6 +38,12 @@ class EditInvoiceVM @Inject constructor( amountSats: ULong, ) { viewModelScope.launch { + if (walletRepo.canReceiveWithSpark()) { + editInvoiceEffect( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None) + ) + return@launch + } _isLoading.update { true } val inboundCapacitySats = walletRepo.inboundLiquiditySats() val action = ReceiveLiquidityDecision.additionalLiquidityAction( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 2b96dce0ba..e7855cca87 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 @@ -122,6 +122,7 @@ fun ReceiveQrScreen( onVerifyHardwareAddress: () -> Unit = {}, showPaymentRequestContacts: Boolean = false, onClickPaymentRequestContacts: () -> Unit = {}, + canReceiveWithSpark: Boolean = false, ) { SetMaxBrightness() @@ -134,8 +135,9 @@ fun ReceiveQrScreen( hasReadyChannels, readyInboundLiquiditySats, walletState.bip21AmountSats, + canReceiveWithSpark, ) { - ReceiveLiquidityDecision.canCreateLightningInvoice( + canReceiveWithSpark || ReceiveLiquidityDecision.canCreateLightningInvoice( hasReadyChannels = hasReadyChannels, inboundCapacitySats = readyInboundLiquiditySats, invoiceAmountSats = walletState.bip21AmountSats, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index f0de3aaead..11306c9312 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 @@ -84,6 +84,7 @@ fun ReceiveSheet( } var editInvoiceSourceTab by remember { mutableStateOf(ReceiveTab.SAVINGS) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() + val sparkState by wallet.sparkState.collectAsStateWithLifecycle() val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val paymentRequestContacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() var paymentRequestDraft by remember { @@ -148,6 +149,7 @@ fun ReceiveSheet( onUsdtSelected = onUsdtSelected, walletState = walletState, lightningState = lightningState, + canReceiveWithSpark = sparkState.isReady, onClickReceiveCjit = { navController.navigateTo(ReceiveRoute.TransferUnavailable) }, onClickEditInvoice = { editInvoiceSourceTab = it diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 3cd0b4f7d3..709311f041 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -464,7 +464,7 @@ class HwSendViewModel @Inject constructor( amount = request.amountSats, fee = result.miningFeeSats, feeRate = result.feeRate, - isTransfer = false, + isTransfer = request.isTransfer, channelId = null, walletId = request.walletId, ) @@ -584,6 +584,8 @@ data class HwSendRequest( val paymentRequestId: PaykitPaymentRequestId? = null, val paymentIdentity: String? = null, val paymentDeadlineAt: Instant? = null, + /** Marks the payment's activity as a transfer between the user's own balances. */ + val isTransfer: Boolean = false, ) private data class PendingHwSendBroadcast( 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 4ed56e3571..9796edd72b 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 @@ -522,14 +522,14 @@ private fun ChannelDetailContent( } @Composable -private fun SectionTitle(text: String) { +internal fun SectionTitle(text: String) { VerticalSpacer(32.dp) Caption13Up(text = text, color = Colors.White64) VerticalSpacer(8.dp) } @Composable -private fun SectionRow( +internal fun SectionRow( name: String, valueContent: @Composable () -> Unit, modifier: Modifier = Modifier, 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 8dc0a62774..e1f37f26b3 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 @@ -1,5 +1,6 @@ package to.bitkit.ui.settings.lightning +import android.content.Intent import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image @@ -38,6 +39,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import kotlinx.collections.immutable.ImmutableList @@ -51,11 +53,13 @@ import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.ChannelStatusUi import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.LightningChannel +import to.bitkit.ui.components.MoneyCaptionB import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.SyncNodeView @@ -70,6 +74,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.shareZipFile import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.getBlockExplorerUrl import to.bitkit.ui.utils.withAccent private const val CLOSED_CHANNEL_ALPHA = 0.64f @@ -102,6 +107,8 @@ fun LightningConnectionsScreen( onClickChannel = { channelUi -> navController.navigateTo(Routes.ChannelDetail(channelUi.details.channelId)) }, + onClickSpark = { navController.navigateTo(Routes.SparkDetail) }, + onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) }, onRefresh = { viewModel.onPullToRefresh() }, @@ -116,6 +123,8 @@ private fun Content( onClickAddConnection: () -> Unit = {}, onClickExportLogs: () -> Unit = {}, onClickChannel: (ChannelUi) -> Unit = {}, + onClickSpark: () -> Unit = {}, + onOpenTx: (String) -> Unit = {}, onRefresh: () -> Unit = {}, ) { var showClosed by remember { mutableStateOf(false) } @@ -150,7 +159,8 @@ private fun Content( onRefresh = onRefresh, modifier = Modifier.fillMaxSize() ) { - val isEmpty = uiState.openChannels.isEmpty() && + val isEmpty = uiState.spark == null && + uiState.openChannels.isEmpty() && uiState.pendingConnections.isEmpty() && uiState.closedChannels.isEmpty() @@ -161,7 +171,11 @@ private fun Content( .fillMaxSize() ) { VerticalSpacer(16.dp) - LightningBalancesSection(uiState.localBalance, uiState.remoteBalance) + LightningBalancesSection( + uiState.localBalance, + uiState.remoteBalance, + isReceivingUnlimited = uiState.isSparkReceiving + ) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) EmptyStateContent(modifier = Modifier.weight(1f)) @@ -183,7 +197,11 @@ private fun Content( .verticalScroll(rememberScrollState()) ) { VerticalSpacer(16.dp) - LightningBalancesSection(uiState.localBalance, uiState.remoteBalance) + LightningBalancesSection( + uiState.localBalance, + uiState.remoteBalance, + isReceivingUnlimited = uiState.isSparkReceiving + ) HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) if (!uiState.isNodeRunning) { @@ -191,6 +209,12 @@ private fun Content( SyncingRow() } + uiState.spark?.let { spark -> + VerticalSpacer(16.dp) + Caption13Up(stringResource(R.string.lightning__spark), color = Colors.White64) + SparkItem(spark = spark, onClick = onClickSpark, onOpenTx = onOpenTx) + } + // Pending Channels Section if (uiState.pendingConnections.isNotEmpty()) { VerticalSpacer(16.dp) @@ -269,7 +293,7 @@ private fun Content( } @Composable -private fun LightningBalancesSection(localBalance: ULong, remoteBalance: ULong) { +private fun LightningBalancesSection(localBalance: ULong, remoteBalance: ULong, isReceivingUnlimited: Boolean = false) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -285,12 +309,20 @@ private fun LightningBalancesSection(localBalance: ULong, remoteBalance: ULong) balance = remoteBalance, icon = R.drawable.ic_received, color = Colors.White, + // Spark receives without inbound liquidity, so no channel caps receiving. + valueText = stringResource(R.string.lightning__spark_receiving_unlimited).takeIf { isReceivingUnlimited }, ) } } @Composable -private fun BalanceColumn(label: String, balance: ULong, @DrawableRes icon: Int, color: Color) { +private fun BalanceColumn( + label: String, + balance: ULong, + @DrawableRes icon: Int, + color: Color, + valueText: String? = null, +) { Column { Caption13Up(text = label, color = Colors.White64) VerticalSpacer(8.dp) @@ -303,7 +335,7 @@ private fun BalanceColumn(label: String, balance: ULong, @DrawableRes icon: Int, tint = color, modifier = Modifier.size(22.dp) ) - Title(text = balance.toLong().formatToModernDisplay(), color = color) + Title(text = valueText ?: balance.toLong().formatToModernDisplay(), color = color) } } } @@ -374,6 +406,106 @@ private fun ChannelItem( } } +@Composable +private fun SparkItem( + spark: SparkUi, + onClick: () -> Unit, + onOpenTx: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickableAlpha { onClick() } + .testTag("SparkConnection") + ) { + VerticalSpacer(16.dp) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + BodyMSB( + text = stringResource(R.string.lightning__spark_provider_value), + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(24.dp) + ) + } + VerticalSpacer(8.dp) + LightningChannel( + capacity = spark.balanceSats.toLong(), + localBalance = spark.balanceSats.toLong(), + remoteBalance = 0L, + status = ChannelStatusUi.OPEN, + showReceiving = false, + ) + spark.pendingDeposits.forEach { SparkDepositRow(it, onOpenTx) } + VerticalSpacer(16.dp) + HorizontalDivider() + } +} + +@Composable +internal fun SparkDepositRow( + depositUi: SparkDepositUi, + onOpenTx: (String) -> Unit, + modifier: Modifier = Modifier, +) { + SparkTransactionRow( + title = stringResource(R.string.lightning__spark_deposit), + txid = depositUi.deposit.txid, + amountSats = depositUi.deposit.amountSats, + confirmations = depositUi.confirmations, + onOpenTx = onOpenTx, + modifier = modifier.testTag("SparkDeposit") + ) +} + +@Composable +internal fun SparkTransactionRow( + title: String, + txid: String, + amountSats: ULong, + onOpenTx: (String) -> Unit, + modifier: Modifier = Modifier, + confirmations: UInt? = null, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clickableAlpha { onOpenTx(txid) } + ) { + Column(modifier = Modifier.weight(1f)) { + CaptionB(text = title) + CaptionB( + text = txid, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.padding(start = 16.dp) + ) { + MoneyCaptionB(sats = amountSats.toLong(), symbol = true) + confirmations?.let { + CaptionB( + text = stringResource(R.string.lightning__spark_confirmations, minOf(it.toInt(), 3)), + color = Colors.Purple, + ) + } + } + } +} + @Composable private fun SyncingRow(modifier: Modifier = Modifier) { Row( 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 eb8d40dd1a..2687b39880 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 @@ -21,19 +21,26 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.OutPoint import to.bitkit.R import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env import to.bitkit.ext.amountOnClose import to.bitkit.ext.calculateRemoteBalance 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 import to.bitkit.repositories.LogsRepo +import to.bitkit.repositories.SparkDeposit +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState +import to.bitkit.repositories.SparkWithdrawal import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -47,6 +54,7 @@ class LightningConnectionsViewModel @Inject constructor( private val blocktankRepo: BlocktankRepo, private val logsRepo: LogsRepo, private val activityRepo: ActivityRepo, + private val sparkRepo: SparkRepo, ) : ViewModel() { companion object { @@ -92,9 +100,10 @@ class LightningConnectionsViewModel @Inject constructor( combine( lightningRepo.lightningState, lightningRepo.lastKnownChannels, - ) { lightningState, channels -> - lightningState.nodeLifecycleState.isRunning() to channels - }.collect { (isNodeRunning, channels) -> + sparkRepo.state, + ) { lightningState, channels, sparkState -> + Triple(lightningState.nodeLifecycleState.isRunning(), channels, sparkState) + }.collect { (isNodeRunning, channels, sparkState) -> val connectionText = context.getString(R.string.lightning__connection) _uiState.update { @@ -106,8 +115,10 @@ class LightningConnectionsViewModel @Inject constructor( pendingConnections = channels.filterPending() .map { it.mapToUiModel(channels, connectionText) } .reversed().toImmutableList(), - localBalance = calculateLocalBalance(channels), + localBalance = calculateLocalBalance(channels).safe() + sparkState.balanceSats.safe(), remoteBalance = channels.calculateRemoteBalance(), + spark = sparkState.toUi(lightningRepo), + isSparkReceiving = sparkState.isReady, ) } } @@ -141,6 +152,7 @@ class LightningConnectionsViewModel @Inject constructor( } suspend fun refreshObservedState() { + sparkRepo.refreshState() lightningRepo.sync() blocktankRepo.refreshOrders() loadClosedChannels() @@ -217,8 +229,39 @@ data class LightningConnectionsUiState( val closedChannels: ImmutableList = persistentListOf(), val localBalance: ULong = 0u, val remoteBalance: ULong = 0u, + val spark: SparkUi? = null, + val isSparkReceiving: Boolean = false, ) +/** The Spark wallet shown as a connection, present once it holds a balance or a deposit. */ +data class SparkUi( + val balanceSats: ULong, + val deposits: ImmutableList = persistentListOf(), + val withdrawals: ImmutableList = persistentListOf(), +) { + val pendingDeposits get() = deposits.filterNot { it.deposit.isClaimed } + val claimedDeposits get() = deposits.filter { it.deposit.isClaimed } +} + +data class SparkDepositUi( + val deposit: SparkDeposit, + /** On-chain confirmations, known for deposits this wallet sent. */ + val confirmations: UInt? = null, +) + +internal suspend fun SparkState.toUi(lightningRepo: LightningRepo): SparkUi? { + if (!isReady) return null + // On regtest Spark always shows, so testers reach its deposit address to fund it from the faucet. + if (balanceSats == 0uL && deposits.isEmpty() && Env.network != Network.REGTEST) return null + return SparkUi( + balanceSats = balanceSats, + deposits = deposits.map { + SparkDepositUi(it, if (it.isClaimed) null else lightningRepo.onchainConfirmations(it.txid)) + }.toImmutableList(), + withdrawals = withdrawals, + ) +} + data class ChannelUi( val name: String, val details: ChannelDetails, diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt new file mode 100644 index 0000000000..e78fc3abaa --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt @@ -0,0 +1,209 @@ +package to.bitkit.ui.settings.lightning + +import android.content.Intent +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.ext.setClipboardText +import to.bitkit.repositories.SparkDeposit +import to.bitkit.repositories.SparkWithdrawal +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.ChannelStatusUi +import to.bitkit.ui.components.LightningChannel +import to.bitkit.ui.components.MoneyCaptionB +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.getBlockExplorerUrl + +@Composable +fun SparkDetailScreen( + onBack: () -> Unit, + onTransferToSavings: () -> Unit, + viewModel: SparkDetailViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Content( + uiState = uiState, + onBack = onBack, + onRefresh = viewModel::onPullToRefresh, + onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) }, + onCopyText = { context.setClipboardText(it) }, + onTransferToSavings = onTransferToSavings, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Content( + uiState: SparkDetailUiState, + onBack: () -> Unit = {}, + onRefresh: () -> Unit = {}, + onOpenTx: (String) -> Unit = {}, + onCopyText: (String) -> Unit = {}, + onTransferToSavings: () -> Unit = {}, +) { + val spark = uiState.spark ?: SparkUi(balanceSats = 0uL) + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__spark), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.weight(1f) + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .testTag("SparkScrollView") + ) { + VerticalSpacer(16.dp) + LightningChannel( + capacity = spark.balanceSats.toLong(), + localBalance = spark.balanceSats.toLong(), + remoteBalance = 0L, + status = ChannelStatusUi.OPEN, + showReceiving = false, + ) + VerticalSpacer(32.dp) + HorizontalDivider() + + SectionTitle(stringResource(R.string.lightning__spark_provider)) + SectionRow( + name = stringResource(R.string.lightning__spark_provider), + valueContent = { CaptionB(text = stringResource(R.string.lightning__spark_provider_value)) }, + ) + VerticalSpacer(12.dp) + BodyS(text = stringResource(R.string.lightning__spark_trust), color = Colors.White64) + + uiState.depositAddress?.let { address -> + SectionRow( + name = stringResource(R.string.wallet__activity_address), + valueContent = { + CaptionB( + text = address, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + textAlign = TextAlign.End, + ) + }, + onClick = { onCopyText(address) }, + modifier = Modifier.testTag("SparkDepositAddress"), + ) + } + + SectionTitle(stringResource(R.string.lightning__balance)) + SectionRow( + name = stringResource(R.string.lightning__spending_label), + valueContent = { MoneyCaptionB(sats = spark.balanceSats.toLong(), symbol = true) }, + ) + + if (spark.pendingDeposits.isNotEmpty()) { + SectionTitle(stringResource(R.string.lightning__spark_deposits_pending)) + spark.pendingDeposits.forEach { SparkDepositRow(it, onOpenTx) } + } + + if (spark.claimedDeposits.isNotEmpty()) { + SectionTitle(stringResource(R.string.lightning__spark_deposits_claimed)) + spark.claimedDeposits.forEach { depositUi -> + SparkDepositRow(depositUi, onOpenTx) + SectionRow( + name = stringResource(R.string.lightning__spark_claim_fee), + valueContent = { + MoneyCaptionB(sats = depositUi.deposit.feeSats.toLong(), symbol = true) + }, + ) + } + } + + if (spark.withdrawals.isNotEmpty()) { + SectionTitle(stringResource(R.string.lightning__savings_confirm__label)) + spark.withdrawals.forEach { withdrawal -> + SparkTransactionRow( + title = stringResource(R.string.wallet__activity_transfer_to_savings), + txid = withdrawal.txid, + amountSats = withdrawal.amountSats, + onOpenTx = onOpenTx, + modifier = Modifier.testTag("SparkWithdrawal") + ) + SectionRow( + name = stringResource(R.string.wallet__activity_fee), + valueContent = { MoneyCaptionB(sats = withdrawal.feeSats.toLong(), symbol = true) }, + ) + } + } + + VerticalSpacer(32.dp) + } + } + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .navigationBarsPadding() + ) { + PrimaryButton( + text = stringResource(R.string.wallet__transfer_to_savings), + onClick = onTransferToSavings, + enabled = spark.balanceSats > 0uL, + modifier = Modifier + .fillMaxWidth() + .testTag("SparkTransferToSavings") + ) + + VerticalSpacer(16.dp) + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = SparkDetailUiState( + spark = SparkUi( + balanceSats = 35_000uL, + deposits = persistentListOf( + SparkDepositUi(SparkDeposit("6c8706b8a9cbd18f", 15_000uL, isClaimed = false), 1u), + SparkDepositUi(SparkDeposit("d0067b2497c7b905", 35_000uL, isClaimed = true, feeSats = 312uL)), + ), + withdrawals = persistentListOf(SparkWithdrawal("4f1d0c55e2a9b713", 4_917uL, feeSats = 1_470uL)), + ), + ), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt new file mode 100644 index 0000000000..ac14080045 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt @@ -0,0 +1,64 @@ +package to.bitkit.ui.settings.lightning + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.lightningdevkit.ldknode.Network +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class SparkDetailViewModel @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, +) : ViewModel() { + private val _uiState = MutableStateFlow(SparkDetailUiState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch(bgDispatcher) { + sparkRepo.state.collect { state -> _uiState.update { it.copy(spark = state.toUi(lightningRepo)) } } + } + if (Env.network == Network.REGTEST) { + viewModelScope.launch(bgDispatcher) { + sparkRepo.depositAddress().onSuccess { address -> + _uiState.update { + it.copy( + depositAddress = address + ) + } + } + } + } + } + + fun onPullToRefresh() { + viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true) } + sparkRepo.refreshState() + _uiState.update { it.copy(spark = sparkRepo.state.value.toUi(lightningRepo)) } + delay(500.milliseconds) + _uiState.update { it.copy(isRefreshing = false) } + } + } +} + +@Immutable +data class SparkDetailUiState( + val spark: SparkUi? = null, + val isRefreshing: Boolean = false, + /** Spark's on-chain deposit address, shown on regtest to fund Spark from Lightspark's regtest faucet. */ + val depositAddress: String? = null, +) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 8e351ba227..56c740e63f 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -20,6 +20,7 @@ import to.bitkit.models.safe import to.bitkit.models.toBalance import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf @@ -33,6 +34,7 @@ class DeriveBalanceStateUseCase @Inject constructor( private val transferRepo: TransferRepo, private val settingsStore: SettingsStore, private val hwWalletRepo: HwWalletRepo, + private val sparkRepo: SparkRepo, ) { suspend operator fun invoke(): Result = withContext(bgDispatcher) { runCatching { @@ -50,16 +52,23 @@ class DeriveBalanceStateUseCase @Inject constructor( val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance()) val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe() - val totalLightningSats = afterClosingChannels.safe() - lingeringCoopCloseSats.safe() + val sparkSats = sparkRepo.state.value.balanceSats + val afterLingeringCoopClose = afterClosingChannels.safe() - lingeringCoopCloseSats.safe() + val totalLightningSats = afterLingeringCoopClose.safe() + sparkSats.safe() val balanceState = BalanceState( totalOnchainSats = totalOnchainSats, channelFundableBalance = channelFundableBalance, totalLightningSats = totalLightningSats, - maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), + // A payment goes over one rail, so the larger of the two balances caps it; Spark keeps its fee. + maxSendLightningSats = maxOf( + lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), + sparkRepo.state.value.sendableSats, + ), maxSendOnchainSats = getMaxSendAmount(balanceDetails), balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), - balanceInTransferToSpending = pendingChannelsSats, + balanceInTransferToSpending = pendingChannelsSats.safe() + + sparkRepo.state.value.pendingDepositSats.safe(), hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 95bcf05879..6fabcf9943 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -773,6 +773,7 @@ class AppViewModel @Inject constructor( observeReceiveSheetInvoice() observeLdkNodeEvents() observeLightningUsableChannels() + observeSparkReady() observePublicPaykitEndpoints() observePublicPaykitInvoiceExpiry() observePrivatePaykitContacts() @@ -850,6 +851,17 @@ class AppViewModel @Inject constructor( } } + private fun observeSparkReady() { + viewModelScope.launch { + lightningRepo.isSparkReady.filter { it }.collect { + refreshPaykitEndpointsAfterChannelAvailabilityChanged( + reason = "spark ready", + forceRefreshLightning = true, + ) + } + } + } + @OptIn(FlowPreview::class) private fun observePublicPaykitEndpoints() { viewModelScope.launch { @@ -1963,6 +1975,15 @@ class AppViewModel @Inject constructor( request, IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable, ) + } else if ( + result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList && + requestedPaymentRequestId.value == request.id + ) { + finishFailedRequestedPaymentRequestPresentation( + request = request, + reason = result?.incomingPaymentRequestFailureReason + ?: IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + ) } else { deferPaymentRequestPresentation( request = request, @@ -1985,6 +2006,24 @@ class AppViewModel @Inject constructor( return true } + private fun finishFailedRequestedPaymentRequestPresentation( + request: PaykitPaymentRequest, + reason: IncomingPaykitPaymentRequestFailureReason, + ) { + paykitPaymentRequestDiagnostics.logPresentationRejection(request.counterparty, reason) + val restorePaymentRequestSheet = shouldRestorePaymentRequestSheet + paymentRequestPresentationGeneration++ + clearRequestedPaymentRequest() + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.slashtags__error_pay_waiting_msg), + testTag = "PaymentRequestWaitingForDetailsToast", + ) + if (restorePaymentRequestSheet) showSheet(Sheet.PaymentRequests) + completePaymentRequestPresentation(request) + } + private fun finishPrivateLinkPendingPaymentRequestPresentation(request: PaykitPaymentRequest) { paykitPaymentRequestDiagnostics.logPresentationRejection( request.counterparty, @@ -7037,18 +7076,22 @@ class AppViewModel @Inject constructor( paymentDeadlineAt = paymentDeadlineAt, onBeforeSend = onBeforeSend, ).onSuccess { hash -> - // Wait until matching payment event is received (with timeout for hold invoices) - val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { - when (it) { - is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) - is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure( - LightningPaymentFailedError(reason = it.reason, paymentRequest = bolt11) - ) - ) + fun outcome(event: Event): Result? = when (event) { + is Event.PaymentSuccessful if event.paymentHash == hash -> Result.success(hash) + is Event.PaymentFailed if event.paymentHash == hash -> Result.failure( + LightningPaymentFailedError(reason = event.reason, paymentRequest = bolt11) + ) - else -> WatchResult.Continue() + else -> null + } + // Wait until matching payment event is received (with timeout for hold invoices) + val result = coroutineScope { + val watch = async(start = CoroutineStart.UNDISPATCHED) { + lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { + outcome(it)?.let { result -> WatchResult.Complete(result) } ?: WatchResult.Continue() + } } + lightningRepo.settledSparkSendEvent(hash)?.let(::outcome)?.also { watch.cancel() } ?: watch.await() } return result ?: Result.failure(PaymentPendingException(hash)) } diff --git a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt index 350c2c716b..04605d199f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BackupsViewModel.kt @@ -18,6 +18,7 @@ import to.bitkit.models.BackupItemStatus import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo import javax.inject.Inject @HiltViewModel @@ -25,6 +26,7 @@ class BackupsViewModel @Inject constructor( private val cacheStore: CacheStore, private val backupRepo: BackupRepo, private val lightningRepo: LightningRepo, + private val sparkRepo: SparkRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(BackupStatusUiState()) @@ -37,7 +39,10 @@ class BackupsViewModel @Inject constructor( private fun collectState() { viewModelScope.launch { cacheStore.backupStatuses.collect { cachedStatuses -> - val categories = BackupCategory.entries.map { category -> + // Spark's exit data shows only where Spark runs. + val categories = BackupCategory.entries.filter { + it != BackupCategory.SPARK_EXIT || sparkRepo.state.value.isSupported + }.map { category -> val cachedStatus = cachedStatuses[category] ?: BackupItemStatus(synced = 0, required = 1) category.toUiState(cachedStatus) } diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 2e25889872..16c4935219 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -133,6 +133,24 @@ class SettingsViewModel @Inject constructor( } } + val hasSeenSpendingIntro = settingsStore.data.map { it.hasSeenSpendingIntro } + .asStateFlow(initialValue = false) + + fun setHasSeenSpendingIntro(value: Boolean) { + viewModelScope.launch { + settingsStore.update { it.copy(hasSeenSpendingIntro = value) } + } + } + + 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 2c9afacd1f..b11f8cc82c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -8,6 +8,7 @@ import com.synonym.bitkitcore.BoltzPairInfo import com.synonym.bitkitcore.BoltzSwapEvent import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.ImmutableSet import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job @@ -36,8 +37,8 @@ import to.bitkit.ext.toUserMessage import to.bitkit.models.Toast import to.bitkit.models.TransferType import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.SparkRepo 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.Logger @@ -56,17 +57,24 @@ 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 walletRepo: WalletRepo, private val transferRepo: TransferRepo, private val boltzService: BoltzService, + private val sparkRepo: SparkRepo, private val clock: Clock, ) : ViewModel() { private val _isForceTransferLoading = MutableStateFlow(false) val isForceTransferLoading = _isForceTransferLoading.asStateFlow() - private val _selectedChannelIdsState = MutableStateFlow>(emptySet()) - val selectedChannelIdsState = _selectedChannelIdsState.asStateFlow() + private val _savingsSelection = MutableStateFlow(SavingsSelection()) + val savingsSelection = _savingsSelection.asStateFlow() + + private val _sparkWithdrawFeeSats = MutableStateFlow(null) + val sparkWithdrawFeeSats = _sparkWithdrawFeeSats.asStateFlow() + + private var sparkFeeJob: Job? = null + private var sparkFeeBalanceSats: ULong? = null + private var sparkAddress: String? = null private val isNodeRunning = lightningRepo.lightningState.map { it.nodeStatus?.isRunning ?: false } .stateIn(viewModelScope, SharingStarted.Lazily, false) @@ -74,6 +82,11 @@ class TransferViewModel @Inject constructor( // region Savings private var channelsToClose = emptyList() + private var withdrawsSpark = false + + /** Spark's part of the last Transfer to Savings failed, so its balance stayed in Spending. */ + private val _isSparkWithdrawFailed = MutableStateFlow(false) + val isSparkWithdrawFailed = _isSparkWithdrawFailed.asStateFlow() /** * How the LN -> onchain "transfer to savings" is executed. Closing a channel is the @@ -107,10 +120,16 @@ class TransferViewModel @Inject constructor( * thing that unlocks the swap, so every failure simply leaves it null and the transfer * falls back to closing a channel. * Cancels any in-flight quote so a slower earlier request cannot overwrite a newer one. + * [channelSendableSat] is what the selected channels can send, so Spark's balance never sizes the swap. */ - fun loadSavingsSwapQuote(requestedSat: ULong) { + fun loadSavingsSwapQuote(requestedSat: ULong, channelSendableSat: ULong) { if (!boltzService.isSwapSupported) return savingsSwapQuoteJob?.cancel() + if (requestedSat == 0uL) { + pendingSwapAmountSat = 0uL + _savingsSwapState.update { it.copy(isLoading = false, quote = null, minSat = 0uL, maxSat = 0uL) } + return + } savingsSwapQuoteJob = viewModelScope.launch { val isSwapEnabled = boltzService.isSwapEnabled() _savingsSwapState.update { it.copy(isSwapEnabled = isSwapEnabled) } @@ -134,7 +153,7 @@ class TransferViewModel @Inject constructor( // Reserve headroom for Lightning routing fees. Paying an invoice for 100% of // outbound capacity leaves nothing for fees and fails with RouteNotFound, so cap // the swap at outbound minus ~1% (with a small floor). - val spendable = walletRepo.balanceState.value.maxSendLightningSats.toLong() + val spendable = channelSendableSat.toLong() val routingReserve = (spendable / 100).coerceAtLeast(MIN_LN_ROUTING_FEE_RESERVE_SATS) val sendable = (spendable - routingReserve).coerceAtLeast(0).toULong() val maxSat = minOf(requestedSat, limits.maximalSat, sendable) @@ -200,6 +219,7 @@ class TransferViewModel @Inject constructor( fun startSavingsSwap() { if (savingsSwapJob?.isActive == true || _savingsSwapResult.value != null) return savingsSwapJob = viewModelScope.launch { + if (withdrawsSpark) withdrawSparkToSavings() val result = executeSavingsSwap() _savingsSwapResult.update { result } } @@ -279,8 +299,35 @@ class TransferViewModel @Inject constructor( return SavingsSwapResult.Failure(event.reason.toUserMessage(context)) } - fun setSelectedChannelIds(channelIds: Set) { - _selectedChannelIdsState.update { channelIds } + fun setSavingsSelection(selection: SavingsSelection) { + _savingsSelection.update { selection } + } + + /** Prices Spark's withdrawal to savings for the confirm screen; null until priced or when it can't be. */ + fun loadSparkWithdrawFee(balanceSats: ULong) { + val isPriced = sparkFeeJob?.isActive == true || _sparkWithdrawFeeSats.value != null + if (balanceSats == sparkFeeBalanceSats && isPriced) return + sparkFeeJob?.cancel() + sparkFeeBalanceSats = balanceSats + _sparkWithdrawFeeSats.update { null } + sparkFeeJob = viewModelScope.launch { + val address = sparkWithdrawAddress() ?: return@launch + val result = withTimeoutOrNull(SWAP_QUOTE_TIMEOUT) { sparkRepo.estimateWithdrawAllFeeSats(address) } + if (result == null) Logger.warn("Timed out estimating Spark withdrawal fee", context = TAG) + result + ?.onSuccess { fee -> _sparkWithdrawFeeSats.update { fee } } + ?.onFailure { Logger.warn("Failed to estimate Spark withdrawal fee", it, context = TAG) } + if (result?.isSuccess != true) sparkFeeBalanceSats = null + } + } + + /** One Savings address for Spark's withdrawal, priced on the confirm screen and then paid to. */ + private suspend fun sparkWithdrawAddress(): String? { + sparkAddress?.let { return it } + return lightningRepo.newAddress() + .onFailure { Logger.error("Failed to get address for Spark withdrawal", it, context = TAG) } + .getOrNull() + ?.also { sparkAddress = it } } /** @@ -290,22 +337,53 @@ class TransferViewModel @Inject constructor( */ fun onTransferToSavingsConfirm( channels: List, - mode: SavingsTransferMode = resolveSavingsTransferMode(), + withdrawSpark: Boolean, + mode: SavingsTransferMode = resolveSavingsTransferMode(channels), ) { _savingsTransferMode.update { mode } // Drop any outcome from an earlier attempt so the progress screen cannot act on it. _savingsSwapResult.update { null } - _selectedChannelIdsState.update { emptySet() } + _isSparkWithdrawFailed.update { false } + _savingsSelection.update { SavingsSelection() } channelsToClose = channels + withdrawsSpark = withdrawSpark } - private fun resolveSavingsTransferMode(): SavingsTransferMode = - if (_savingsSwapState.value.quote != null) SavingsTransferMode.SWAP else SavingsTransferMode.CLOSE + private fun resolveSavingsTransferMode(channels: List): SavingsTransferMode = + if (channels.isNotEmpty() && _savingsSwapState.value.quote != null) { + SavingsTransferMode.SWAP + } else { + SavingsTransferMode.CLOSE + } - /** Closes the channels selected earlier, pending closure */ - suspend fun closeSelectedChannels() = closeChannels(channelsToClose) + /** Whether Transfer to Savings closes channels, rather than only withdrawing Spark's balance. */ + fun hasChannelsToClose(): Boolean = channelsToClose.isNotEmpty() + + /** Withdraws Spark's balance when selected, then closes the channels selected earlier, pending closure */ + suspend fun closeSelectedChannels(): List { + if (withdrawsSpark) withdrawSparkToSavings() + return closeChannels(channelsToClose) + } + + private suspend fun withdrawSparkToSavings() { + sparkFeeJob?.join() + val balance = sparkRepo.state.value.balanceSats + if (balance == 0uL) return + val address = sparkWithdrawAddress() + if (address == null) { + _isSparkWithdrawFailed.update { true } + return + } + sparkRepo.withdrawAll(address) + .onSuccess { + sparkAddress = null + sparkFeeBalanceSats = null + } + .onFailure { _isSparkWithdrawFailed.update { true } } + } private suspend fun closeChannels(channels: List): List { + if (channels.isEmpty()) return emptyList() lightningRepo.awaitPeerConnected() val channelsFailedToClose = coroutineScope { channels.map { channel -> @@ -445,6 +523,23 @@ class TransferViewModel @Inject constructor( } } +/** + * What Transfer to Savings moves: the open channels picked in [channelIds], null for all of them, and whether + * Spark's balance goes too. The default moves everything. + */ +@Immutable +data class SavingsSelection( + val channelIds: ImmutableSet? = null, + val includesSpark: Boolean = true, +) { + val isAll: Boolean get() = channelIds == null && includesSpark + + fun channels(openChannels: List): List = + channelIds?.let { ids -> openChannels.filter { it.channelId in ids } } ?: openChannels + + fun withdrawsSpark(sparkSats: ULong): Boolean = includesSpark && sparkSats > 0uL +} + /** Whether a transfer to savings swaps funds out or closes a channel (default). */ enum class SavingsTransferMode { SWAP, CLOSE } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index d482e991b4..7de4230089 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -50,6 +50,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.NodeStartYieldedToStopError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.RecoveryModeError +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WipeInProgressError @@ -80,6 +81,7 @@ class WalletViewModel @Inject constructor( private val connectivityRepo: ConnectivityRepo, private val boltzService: BoltzService, private val connectionRecovery: WalletConnectionRecovery, + sparkRepo: SparkRepo, ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" @@ -99,6 +101,7 @@ class WalletViewModel @Inject constructor( } val lightningState = lightningRepo.lightningState + val sparkState = sparkRepo.state val walletState = walletRepo.walletState val balanceState = walletRepo.balanceState diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c8973f2586..39227d7601 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -158,6 +158,7 @@ استخدم محفظة أخرى تحويل من محفظة الأجهزة رصيد الإنفاق + أسهل خيار هو استخدام مدخرات Bitkit الخاصة بك أو إرسال بيتكوين من محفظة مختلفة. موّل <accent>رصيد الإنفاق</accent> إعداد يدوي السعة الواردة @@ -168,6 +169,9 @@ ارتفاع أرشفة مراقب القناة حد HTLC الصادر التالي الحد الأدنى لـ HTLC الصادر التالي + قبل أن تتمكن من تحويل الأموال من رصيد مدخراتك، تحتاج إلى إرسال Bitcoin إلى محفظة Bitkit الخاصة بك. + تمويل المحفظة + لا توجد أموال متاحة معرّف عقدة LDK عقدة Lightning وقت مزامنة محفظة Lightning @@ -206,16 +210,56 @@ يرجى الانتظار، جارٍ تحويل أموالك. يجب أن يستغرق هذا <accent>±10 ثوانٍ.</accent> الأموال\n<accent>قيد التحويل</accent> وقت مزامنة درجات تحديد المسار + لم يرَ Spark تحويلك بعد. يُضاف بعد 3 تأكيدات؛ يمكنك مغادرة هذه الشاشة. + ادفع ₿ {amount} + أصبح الفوري يكلّف الآن ₿ {amount}. ادفعه، أو انتظر 3 تأكيدات، نحو 30 دقيقة، برسوم أقل. + جارٍ الإضافة إلى الإنفاق + رآه Spark + تم إرسال التحويل + الفوري غير متاح لهذا التحويل. يضيفه Spark إلى رصيد الإنفاق بعد 3 تأكيدات، نحو 30 دقيقة. + متابعة استخدام Bitkit + Spark + رسوم المطالبة + %1$d/3 تأكيدات + إيداع + يضيفه Spark إلى رصيد الإنفاق. + بانتظار أن يرى Spark تحويلك. + يضيفه Spark إلى رصيد الإنفاق بعد {standard} تأكيدات، أي بعد نحو {minutes} دقيقة. + انتظر + إيداعات تمت المطالبة بها + إيداعات قيد التنفيذ + المزوّد + Spark عبر Breez + بلا حد + يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة. قابل للإنفاق على السلسلة الإنفاق + لا تكفي مدخراتك لتغطية هذا التحويل ورسومه. جرّب مبلغًا أصغر. + المدخرات غير كافية المبلغ الذي يمكنك تحويله إلى رصيد الإنفاق محدود حاليًا بـ ₿ {amount}. الحد الأقصى لرصيد الإنفاق + حوّل أكثر من ₿ {amount} لتغطية رسوم إضافته إلى رصيد الإنفاق. + المبلغ صغير جدًا 25% + تحويل\n<accent>إلى الإنفاق</accent> إلى الإنفاق + الرسوم أعلى بمقدار {amount} لأن معدلات الرسوم أو رصيد مدخراتك تغيّر أثناء وجودك في هذه الشاشة. اسحب مرة أخرى للتأكيد. + تغيّرت الرسوم + جاهز خلال دقائق + بعد 3 تأكيدات، نحو 30 دقيقة رسوم الخدمة رسوم الشبكة + تعذّر على Bitkit الحصول على الرسوم الحالية لهذا التحويل. تحقق من اتصالك وحاول مرة أخرى. + الرسوم غير متاحة + رسوم الإنفاق الإجمالي + حتى + ابدأ + حوّل الأموال إلى رصيد الإنفاق للاستمتاع بمعاملات فورية ورخيصة مع الأصدقاء والعائلة والتجار. + تحويل\n<accent>إلى الإنفاق</accent> رصيد الإنفاق + يدفع الفوري للمزوّد لإضافة تحويلك إلى رصيد الإنفاق قبل تأكيده، عادةً خلال دقائق.\n\nأما لاحقًا فينتظر 3 تأكيدات، نحو 30 دقيقة، برسوم أقل. + Transfer\n<accent>speed</accent> الحالة الدعم إجمالي احتياطي قنوات Anchor @@ -226,10 +270,16 @@ يرجى\n<accent>التأكيد</accent> تحويل الأموال اسحب للتحويل + فتح Trezor Connect جارٍ التحويل + ابدأ + موّل رصيد الإنفاق للاستمتاع بمعاملات فورية ورخيصة مع الأصدقاء والعائلة والتجار. + رصيد\n<accent>الإنفاق</accent> نجح التحويل تم تحويل أموالك. يمكنك استخدام هذه الأموال فورًا. + تم تحويل أموالك ورصيد الإنفاق جاهز للاستخدام. تم نقل الأموال\n<accent>إلى المدخرات</accent> + تم نقل الأموال\n<accent>إلى الإنفاق</accent> يرجى الانتظار حتى يتصل Bitkit بشبكة الدفع (±10 ثوانٍ). يرجى الانتظار حتى يتصل Bitkit بشبكة البيتكوين (±10 ثوانٍ). أرصدة المحفظة @@ -528,6 +578,7 @@ جهات الاتصال الملف الشخصي الإعدادات + بيانات الخروج من Spark العلامات سجل المعاملات التعزيزات والتحويلات @@ -1209,11 +1260,16 @@ رسوم الشبكة ستستلم رسوم الخدمة + رسوم Spark بدأ التحويل وهو <accent>قيد التسوية على السلسلة</accent>. سيتم تحديث رصيد مدخراتك تلقائيًا عند اكتماله. التحويل\n<accent>في الطريق</accent> + تعذّر نقل رصيد Spark إلى المدخرات ويبقى في رصيد الإنفاق. + تعذّر نقل رصيد Spark إلى المدخرات ويبقى في رصيد الإنفاق. اتصالاتك قيد النقل إلى المدخرات. <accent>جارٍ الاتصال</accent>\nبالشبكة يرجى إعادة توصيل جهازك، يبدو أنه غير متصل بهاتفك. إعادة توصيل الجهاز + وقّع باستخدام\n<accent>جهازك</accent> + تم توقيع\n<accent>المعاملة</accent> التحويل جاهز خلال %s التحويل غير متاح لم تعد التحويلات التلقائية إلى رصيد الإنفاق متاحة. استخدم عقدتك الخاصة، أو أنشئ اتصالًا مع مزوّد آخر. 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 f62df577aa..dac1da7456 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -165,6 +165,7 @@ 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> Configuración Manual Capacidad entrante @@ -175,6 +176,9 @@ 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 @@ -213,16 +217,56 @@ 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 + Spark todavía no vio tu transferencia. Se agrega después de 3 confirmaciones; puedes salir de esta pantalla. + Pagar ₿ {amount} + Instantáneo ahora cuesta ₿ {amount}. Págalo o espera 3 confirmaciones, unos 30 minutos, con una comisión menor. + Agregando a gastos + Visto por Spark + Transferencia enviada + Instantáneo no está disponible para esta transferencia. Spark la agrega a tu saldo para gastos después de 3 confirmaciones, unos 30 minutos. + Continuar Usando Bitkit + Spark + Comisión de reclamo + %1$d/3 confirmaciones + Depósito + Spark lo está agregando a tu saldo de gastos. + Esperando a que Spark vea tu transferencia. + Spark lo agrega a tu saldo de gastos después de {standard} confirmaciones, en unos {minutes} minutos. + Esperar + Depósitos reclamados + Depósitos en curso + Proveedor + Spark mediante Breez + Sin límite + Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable onchain Gasto + Sus ahorros no alcanzan para cubrir esta transferencia y sus comisiones. Intente con un monto menor. + Ahorros insuficientes El monto que puedes transferir a tu saldo para gastar está limitado a ₿ {amount}. Saldo máximo de gastos + Transfiere más de ₿ {amount} para cubrir las comisiones de agregarlo a tu saldo para gastos. + Monto demasiado bajo 25% + Transferir\n<accent>a gastos</accent> Hacia Gastos + 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 cambiaron + Listo en unos minutos + Después de 3 confirmaciones, unos 30 minutos Comisión de servicio Comisiones de red + Bitkit no pudo obtener las comisiones actuales de esta transferencia. Revisa tu conexión e inténtalo de nuevo. + Comisiones no disponibles + Comisión de gastos Total + hasta + 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 + Instantáneo le paga al proveedor para agregar tu transferencia a tu saldo para gastos antes de que se confirme, normalmente en unos minutos.\n\nDespués espera 3 confirmaciones, unos 30 minutos, con una comisión menor. + Transfer\n<accent>speed</accent> Estatus Soporte Reserva total de canales ancla @@ -233,10 +277,16 @@ Por favor,\n<accent>confirme</accent> Transferir fondos Desliza para enviar + Abrir Trezor Connect 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.). Por favor, espere a que Bitkit se conecte a la red bitcoin (±10 seg.). Saldos de billetera @@ -549,6 +599,7 @@ Contactos Perfil Ajustes + Datos de salida de Spark Etiquetas Registro de transacciones Impulsos y transferencias @@ -1239,11 +1290,16 @@ Comisión de red Recibirá Comisión de servicio + Comisión de Spark 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> + Tu saldo de Spark no pudo pasar a ahorros y se queda en tu saldo de gastos. + Tu saldo de Spark no pudo pasar a ahorros y se queda en tu saldo de gastos. Tus conexiones se están pasando a ahorros. <accent>Conectando</accent>\na la red 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. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index f87eac631f..334505505c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -162,6 +162,7 @@ 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> Configuració manual Capacitat d\'entrada @@ -172,6 +173,9 @@ 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 @@ -210,16 +214,56 @@ 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 + Spark encara no ha vist la teva transferència. S\'afegeix després de 3 confirmacions; pots sortir d\'aquesta pantalla. + Paga ₿ {amount} + Instant ara costa ₿ {amount}. Paga-ho o espera 3 confirmacions, uns 30 minuts, amb una comissió més baixa. + Afegint a despesa + Vist per Spark + Transferència enviada + Instant no està disponible per a aquesta transferència. Spark l\'afegeix al teu saldo de despesa després de 3 confirmacions, uns 30 minuts. + Continua utilitzant Bitkit + Spark + Comissió de reclamació + %1$d/3 confirmacions + Dipòsit + Spark ho està afegint al teu saldo de despeses. + Esperant que Spark vegi la teva transferència. + Spark ho afegeix al teu saldo de despeses després de {standard} confirmacions, d\'aquí a uns {minutes} minuts. + Espera + Dipòsits reclamats + Dipòsits en curs + Proveïdor + Spark mitjançant Breez + Sense límit + Aquest saldo es guarda a Spark, gestionat per Lightspark, Breez i Flashnet. Està segur mentre almenys un d’ells actuï amb honestedat. Poden pausar l’accés, i sortir-ne sense ells requereix una sortida on-chain. Gastable Onchain Despesa + Els teus estalvis no poden cobrir aquesta transferència i les seves tarifes. Prova un import més petit. + Estalvis insuficients L\'import que pots transferir al teu saldo de despesa està limitat a ₿ {amount}. Màxim del saldo de despesa + Transfereix més de ₿ {amount} perquè cobreixi les comissions d\'afegir-ho al teu saldo de despesa. + Import massa baix 25% + Transferir\n<accent>a despesa</accent> A despesa + 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 han canviat + A punt en pocs minuts + Després de 3 confirmacions, uns 30 minuts Tarifes de servei Tarifes de xarxa + Bitkit no ha pogut obtenir les comissions actuals d\'aquesta transferència. Comprova la connexió i torna-ho a provar. + Comissions no disponibles + Comissió de despesa Total + fins a + 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 + Instant paga el proveïdor per afegir la teva transferència al saldo de despesa abans que es confirmi, normalment en pocs minuts.\n\nDesprés espera 3 confirmacions, uns 30 minuts, amb una comissió més baixa. + Transfer\n<accent>speed</accent> Estat Suport Reserva total de canals Anchor @@ -230,10 +274,16 @@ Si us plau\n<accent>confirma</accent> Transferir fons Desplaçar per Transferir + Obrir Trezor Connect 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). Si us plau, espereu que Bitkit es connecti a la xarxa bitcoin (±10 segons). Saldos de la cartera @@ -543,6 +593,7 @@ Contactes Perfil Configuracions + Dades de sortida de Spark Etiquetes Registre de transaccions Impulsos i transferències @@ -1233,11 +1284,16 @@ Tarifa de xarxa Rebràs Tarifa de servei + Tarifa de Spark 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> + El saldo de Spark no s\'ha pogut moure a estalvis i es queda al saldo de despesa. + El saldo de Spark no s\'ha pogut moure a estalvis i es queda al saldo de despesa. Les teves connexions s\'estan movent a estalvis. <accent>Connectant</accent>\na la xarxa 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. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index a6b2454328..91f4805c90 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -165,6 +165,7 @@ 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> Manuální nastavení Příchozí kapacita @@ -175,6 +176,9 @@ 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 @@ -213,16 +217,56 @@ 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 + Spark tvůj převod zatím neviděl. Připíše se po 3 potvrzeních; tuto obrazovku můžeš opustit. + Zaplatit ₿ {amount} + Okamžitě nyní stojí ₿ {amount}. Zaplať, nebo počkej na 3 potvrzení, asi 30 minut, s nižším poplatkem. + Přidávání do výdajů + Viděno Sparkem + Převod odeslán + Okamžitě není pro tento převod dostupné. Spark ho přidá do tvého zůstatku pro výdaje po 3 potvrzeních, asi za 30 minut. + Pokračujte v používání Bitkit + Spark + Poplatek za připsání + %1$d/3 potvrzení + Vklad + Spark ji připisuje na váš zůstatek k utrácení. + Čekáme, až Spark uvidí váš převod. + Spark ji připíše na váš zůstatek k utrácení po {standard} potvrzeních, asi za {minutes} minut. + Počkat + Připsané vklady + Probíhající vklady + Poskytovatel + Spark přes Breez + Bez limitu + Tento zůstatek je uložen na Spark, který provozují Lightspark, Breez a Flashnet. Je v bezpečí, dokud alespoň jeden z nich jedná poctivě. Mohou pozastavit přístup a odchod bez nich vyžaduje on-chain výstup. Utratitelné onchain Útraty + Vaše úspory nepokryjí tento převod a jeho poplatky. Zkuste menší částku. + Nedostatečné úspory Částka, kterou můžete převést na svůj disponibilní zůstatek, je v současné době omezena na ₿ {amount}. Maximální disponibilní zůstatek + Převeď více než ₿ {amount}, aby to pokrylo poplatky za přidání do zůstatku pro výdaje. + Příliš malá částka 25% + Převést\n<accent>do výdajů</accent> Do výdajů + 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 se změnily + Hotovo za pár minut + Po 3 potvrzeních, asi 30 minut Poplatky za služby Síťové poplatky + Bitkit nedokázal získat aktuální poplatky za tento převod. Zkontroluj připojení a zkus to znovu. + Poplatky nedostupné + Poplatek za výdaje Celkem + až + 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 + Okamžitě platí poskytovateli za přidání převodu do zůstatku pro výdaje ještě před potvrzením, obvykle během pár minut.\n\nPozději čeká na 3 potvrzení, asi 30 minut, s nižším poplatkem. + Transfer\n<accent>speed</accent> Status Podpora Celková rezerva kotevních kanálů @@ -233,10 +277,16 @@ Prosím,\n<accent>potvrďte</accent> Převést prostředky Přejeďte prstem k provedení převodu + Otevřít Trezor Connect 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). Prosím vyčkejte, až se Bitkit připojí k síti bitcoin (±10 sekund). Zůstatky peněženky @@ -548,6 +598,7 @@ Kontakty Profil Nastavení + Data pro výstup ze Spark Tagy Transakční protokol Posílení a převody @@ -1237,11 +1288,16 @@ Síťový poplatek Obdržíte Poplatek za službu + Poplatek Spark 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> + Zůstatek Spark se nepodařilo přesunout do úspor a zůstává ve výdajovém zůstatku. + Zůstatek Spark se nepodařilo přesunout do úspor a zůstává ve výdajovém zůstatku. Vaše spojení se přesouvají do úspor. <accent>Připojování</accent>\nk síti 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. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d8a0d88b77..5cea87a889 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -101,20 +101,50 @@ Abhängig von der Gebühr Anderes Wallet verwenden Von HW-Wallet übertragen + Am einfachsten ist es, deine Bitkit-Ersparnisse zu verwenden oder Bitcoin von einer anderen Wallet zu senden. + Spark + Einlösegebühr + %1$d/3 Bestätigungen + Einzahlung + Spark schreibt es deinem Ausgabenguthaben gut. + Warte, bis Spark deine Überweisung sieht. + Spark schreibt es deinem Ausgabenguthaben nach {standard} Bestätigungen gut, in etwa {minutes} Minuten. + Warten + Eingelöste Einzahlungen + Laufende Einzahlungen + Anbieter + Spark über Breez + Kein Limit + Dieses Guthaben liegt auf Spark, betrieben von Lightspark, Breez und Flashnet. Es ist sicher, solange mindestens einer von ihnen ehrlich handelt. Sie können den Zugang sperren, und ein Ausstieg ohne sie erfordert einen On-Chain-Exit. Spending Balance Fund your <accent>spending balance</accent> Von Sparkonto übertragen Manuelle Einrichtung + <accent>zu den Ausgaben</accent> Übertrage Guthaben Bitte\n<accent>bestätige</accent> Swipe zum Übertragen + Trezor Connect öffnen 25% Maximaler Ausgabensaldo + Überweise mehr als ₿ {amount}, damit die Gebühren für dein Ausgabenguthaben gedeckt sind. + Betrag zu klein Der Betrag, den du auf dein Ausgabenkonto übertragen kannst, ist derzeit auf ₿ {amount} begrenzt. Netzgebühren + Bitkit konnte die aktuellen Gebühren für diese Überweisung nicht abrufen. Prüfe deine Verbindung und versuche es erneut. + Gebühren nicht verfügbar + Ausgabengebühr Servicegebühren Zu den Ausgaben + 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. + Gebühren geändert + In wenigen Minuten bereit + Nach 3 Bestätigungen, etwa 30 Minuten Total + bis zu + Los geht\'s + Überweisen Sie Geld auf Ihr Guthaben, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu tätigen. + <accent>zu den Ausgaben</accent> Ü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 @@ -132,7 +162,9 @@ 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 Einsparungen</accent> + Bewegte Mittel\n<accent>zu den Ausgaben</accent> Ihr Geld wurde überwiesen. Sie können diese Mittel sofort verwenden. + Deine Gelder wurden übertragen und dein Ausgabenguthaben ist einsatzbereit. Lightning-Verbindung <accent>Manuelle Einrichtung</accent> Sie können einen externen Knoten verwenden, um eine Lightning-Verbindung manuell zu öffnen. Geben Sie die Details des Knotens ein, um fortzufahren. @@ -148,8 +180,12 @@ Einrichtung der Instant-Zahlungen fehlgeschlagen Ein Fehler ist bei der Einrichtung deines Sofortguthabens aufgetreten. {raw} Ausgabenkonto + Dein Sparkonto reicht nicht aus, um diese Übertragung und ihre Gebühren zu decken. Versuche einen kleineren Betrag. + Unzureichendes Sparguthaben Sparkonto Ausgabenguthaben + Sofort bezahlt den Anbieter dafür, deine Überweisung schon vor der Bestätigung deinem Ausgabenguthaben hinzuzufügen, meist innerhalb weniger Minuten.\n\nSpäter wartet auf 3 Bestätigungen, etwa 30 Minuten, zu einer niedrigeren Gebühr. + Transfer\n<accent>speed</accent> Empfangskapazität Lightning Node LDK Node ID @@ -192,6 +228,9 @@ Channel Monitor Archivhöhe Nächstes ausgehendes HTLC-Limit Nächster ausgehender HTLC Min + Bevor du Geld von deinem Sparkonto übertragen kannst, musst du Bitcoin zu deiner Bitkit Wallet senden. + Wallet aufladen + Keine verfügbaren Mittel Lightning Wallet Sync-Zeit Onchain Wallet Sync-Zeit Anderes @@ -232,11 +271,22 @@ Verbindung inaktiv Rapid Gossip Sync Snapshot-Zeit Pathfinding-Scores Sync-Zeit + Spark hat deine Überweisung noch nicht gesehen. Sie wird nach 3 Bestätigungen gutgeschrieben; du kannst diesen Bildschirm verlassen. + ₿ {amount} zahlen + Sofort kostet jetzt ₿ {amount}. Zahle es oder warte auf 3 Bestätigungen, etwa 30 Minuten, zu einer niedrigeren Gebühr. + Wird hinzugefügt + Von Spark gesehen + Überweisung gesendet + Sofort ist für diese Überweisung nicht verfügbar. Spark fügt sie nach 3 Bestätigungen, etwa 30 Minuten, deinem Ausgabenguthaben hinzu. + Bitkit weiter verwenden Onchain verfügbar Gesamte Anchor-Channels-Reserve Gesamt Lightning Gesamt Onchain ÜBERTRAGUNG LÄUFT + Los geht\'s + Fülle dein Guthaben auf, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu genießen. + Spending\n<accent>Balance</accent> Bitte warte bis sich Bitkit mit dem Zahlungsnetzwerk verbunden hat (±10 Sekunden). Bitte warte bis sich Bitkit mit dem Bitcoin-Netzwerk verbunden hat (±10 Sekunden). Wallet-Guthaben @@ -600,6 +650,7 @@ Transaktionshistorie Boosts & Übertragungen Einstellungen + Spark-Ausstiegsdaten Widgets Tags Profil @@ -1234,11 +1285,16 @@ Netzwerkgebühr Du erhältst Servicegebühr + Spark-Gebü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 Spark-Guthaben konnte nicht in die Ersparnisse verschoben werden und bleibt im Ausgabenguthaben. + Dein Spark-Guthaben konnte nicht in die Ersparnisse verschoben werden und bleibt im Ausgabenguthaben. Deine Verbindungen werden in die Ersparnisse verschoben. <accent>Verbinde</accent>\nmit Netzwerk 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. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 5b589e5087..13bc6c27e3 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -158,6 +158,7 @@ Χρήση άλλου πορτοφολιού Μεταφορά από πορτοφόλι HW Υπόλοιπο δαπανών + Ο ευκολότερος τρόπος είναι να χρησιμοποιήσεις τις αποταμιεύσεις Bitkit ή να στείλεις bitcoin από διαφορετικό πορτοφόλι. Χρηματοδότησε το <accent>υπόλοιπο δαπανών</accent> Χειροκίνητη ρύθμιση Χωρητικότητα εισερχομένων @@ -168,6 +169,9 @@ Ύψος αρχειοθέτησης παρακολούθησης καναλιού Όριο επόμενου εξερχόμενου HTLC Ελάχιστο επόμενο εξερχόμενο HTLC + Πριν μπορέσεις να μεταφέρεις κεφάλαια από το υπόλοιπο αποταμιεύσεων, πρέπει να στείλεις bitcoin στο πορτοφόλι Bitkit. + Χρηματοδότηση πορτοφολιού + Δεν υπάρχουν διαθέσιμα κεφάλαια LDK Node ID Κόμβος Lightning Χρόνος συγχρονισμού πορτοφολιού Lightning @@ -206,16 +210,56 @@ Περίμενε, η μεταφορά κεφαλαίων σου είναι σε εξέλιξη. Αυτό θα διαρκέσει <accent>±10 δευτερόλεπτα.</accent> Κεφάλαια\n<accent>σε μεταφορά</accent> Χρόνος συγχρονισμού βαθμολογιών εύρεσης διαδρομής + Το Spark δεν έχει δει ακόμα τη μεταφορά σου. Προστίθεται μετά από 3 επιβεβαιώσεις· μπορείς να φύγεις από αυτή την οθόνη. + Πλήρωσε ₿ {amount} + Το Άμεσα κοστίζει τώρα ₿ {amount}. Πλήρωσέ το ή περίμενε 3 επιβεβαιώσεις, περίπου 30 λεπτά, με χαμηλότερη χρέωση. + Προσθήκη στις δαπάνες + Το είδε το Spark + Η μεταφορά στάλθηκε + Το Άμεσα δεν είναι διαθέσιμο για αυτή τη μεταφορά. Το Spark την προσθέτει στο υπόλοιπο δαπανών σου μετά από 3 επιβεβαιώσεις, περίπου 30 λεπτά. + Συνέχισε να χρησιμοποιείς το Bitkit + Spark + Χρέωση εξαργύρωσης + %1$d/3 επιβεβαιώσεις + Κατάθεση + Το Spark το προσθέτει στο υπόλοιπο δαπανών σας. + Αναμονή για να δει το Spark τη μεταφορά σας. + Το Spark το προσθέτει στο υπόλοιπο δαπανών σας μετά από {standard} επιβεβαιώσεις, σε περίπου {minutes} λεπτά. + Αναμονή + Εξαργυρωμένες καταθέσεις + Καταθέσεις σε εξέλιξη + Πάροχος + Spark μέσω Breez + Χωρίς όριο + Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain. Διαθέσιμα onchain Δαπάνες + Οι αποταμιεύσεις σου δεν επαρκούν για αυτή τη μεταφορά και τα τέλη της. Δοκίμασε μικρότερο ποσό. + Ανεπαρκείς αποταμιεύσεις Το ποσό που μπορείς να μεταφέρεις στο υπόλοιπο δαπανών περιορίζεται προς το παρόν σε ₿ {amount}. Μέγιστο υπόλοιπο δαπανών + Μετέφερε περισσότερα από ₿ {amount}, ώστε να καλύπτονται οι χρεώσεις για την προσθήκη στο υπόλοιπο δαπανών σου. + Πολύ μικρό ποσό 25% + Μεταφορά\n<accent>σε δαπάνες</accent> Σε δαπάνες + Τα τέλη είναι {amount} υψηλότερα, επειδή τα ποσοστά τελών ή το υπόλοιπο αποταμιεύσεων άλλαξαν ενώ ήσουν σε αυτή την οθόνη. Σύρε ξανά για επιβεβαίωση. + Τα τέλη άλλαξαν + Έτοιμο σε λίγα λεπτά + Μετά από 3 επιβεβαιώσεις, περίπου 30 λεπτά Τέλη υπηρεσίας Τέλη δικτύου + Το Bitkit δεν μπόρεσε να λάβει τις τρέχουσες χρεώσεις για αυτή τη μεταφορά. Έλεγξε τη σύνδεσή σου και δοκίμασε ξανά. + Μη διαθέσιμες χρεώσεις + Χρέωση δαπανών Σύνολο + έως + Ξεκίνα + Μετέφερε κεφάλαια στο υπόλοιπο δαπανών για να απολαύσεις άμεσες και φθηνές συναλλαγές με φίλους, οικογένεια και εμπόρους. + Μεταφορά\n<accent>σε δαπάνες</accent> Υπόλοιπο δαπανών + Το Άμεσα πληρώνει τον πάροχο για να προσθέσει τη μεταφορά σου στο υπόλοιπο δαπανών πριν επιβεβαιωθεί, συνήθως μέσα σε λίγα λεπτά.\n\nΤο Αργότερα περιμένει 3 επιβεβαιώσεις, περίπου 30 λεπτά, με χαμηλότερη χρέωση. + Transfer\n<accent>speed</accent> Κατάσταση Υποστήριξη Συνολικό αποθεματικό anchor καναλιών @@ -226,10 +270,16 @@ Παρακαλώ\n<accent>επιβεβαίωσε</accent> Μεταφορά κεφαλαίων Σύρε για μεταφορά + Άνοιγμα Trezor Connect ΜΕΤΑΦΟΡΑ ΣΕ ΕΞΕΛΙΞΗ + Ξεκίνα + Χρηματοδότησε το υπόλοιπο δαπανών για να απολαύσεις άμεσες και φθηνές συναλλαγές με φίλους, οικογένεια και εμπόρους. + Υπόλοιπο\n<accent>δαπανών</accent> Επιτυχής μεταφορά Τα κεφάλαιά σου μεταφέρθηκαν. Μπορείς να χρησιμοποιήσεις αυτά τα κεφάλαια αμέσως. + Τα κεφάλαιά σου μεταφέρθηκαν και το υπόλοιπο δαπανών είναι έτοιμο για χρήση. Κεφάλαια μεταφέρθηκαν\n<accent>σε αποταμιεύσεις</accent> + Κεφάλαια μεταφέρθηκαν\n<accent>σε δαπάνες</accent> Περίμενε το Bitkit να συνδεθεί στο δίκτυο πληρωμών (±10 δευτερόλεπτα). Περίμενε το Bitkit να συνδεθεί στο δίκτυο bitcoin (±10 δευτερόλεπτα). Υπόλοιπα πορτοφολιού @@ -528,6 +578,7 @@ Επαφές Προφίλ Ρυθμίσεις + Δεδομένα εξόδου Spark Ετικέτες Αρχείο συναλλαγών Ενισχύσεις & Μεταφορές @@ -1215,11 +1266,16 @@ Τέλος δικτύου Θα λάβεις Τέλος υπηρεσίας + Τέλος Spark Η μεταφορά σου ξεκίνησε και <accent>εκκαθαρίζεται onchain</accent>. Το υπόλοιπο αποταμιεύσεων θα ενημερωθεί αυτόματα μόλις ολοκληρωθεί. Η μεταφορά\n<accent>είναι καθ\' οδόν</accent> + Το υπόλοιπο Spark δεν μεταφέρθηκε στις αποταμιεύσεις και παραμένει στο υπόλοιπο δαπανών. + Το υπόλοιπο Spark δεν μεταφέρθηκε στις αποταμιεύσεις και παραμένει στο υπόλοιπο δαπανών. Οι συνδέσεις σας μεταφέρονται στις αποταμιεύσεις. <accent>Σύνδεση</accent>\nστο δίκτυο Σύνδεσε ξανά τη συσκευή υλικού σου, φαίνεται ότι αποσυνδέθηκε από το τηλέφωνό σου. Επανασύνδεση συσκευής υλικού + Υπόγραψε με\n<accent>τη συσκευή σου</accent> + Η συναλλαγή\n<accent>υπογράφηκε</accent> Η ΜΕΤΑΦΟΡΑ ΕΙΝΑΙ ΕΤΟΙΜΗ ΣΕ %s Μεταφορά μη διαθέσιμη Οι αυτόματες μεταφορές στο υπόλοιπο δαπανών δεν είναι πλέον διαθέσιμες. Χρησιμοποιήστε τον δικό σας κόμβο ή ρυθμίστε σύνδεση με άλλον πάροχο. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 34faa374a7..e50e8de4c2 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -164,6 +164,7 @@ 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> Configuración manual Capacidad entrante @@ -174,6 +175,9 @@ 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 @@ -212,16 +216,56 @@ 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 + Spark aún no ha visto su transferencia. Se añade tras 3 confirmaciones; puede salir de esta pantalla. + Pagar ₿ {amount} + Instantáneo ahora cuesta ₿ {amount}. Páguelo o espere 3 confirmaciones, unos 30 minutos, con una comisión menor. + Añadiendo a gastos + Visto por Spark + Transferencia enviada + Instantáneo no está disponible para esta transferencia. Spark la añade a su saldo para gastos tras 3 confirmaciones, unos 30 minutos. + Continuar Usando Bitkit + Spark + Comisión de reclamación + %1$d/3 confirmaciones + Depósito + Spark lo está añadiendo a tu saldo de gastos. + Esperando a que Spark vea tu transferencia. + Spark lo añade a tu saldo de gastos tras {standard} confirmaciones, en unos {minutes} minutos. + Esperar + Depósitos reclamados + Depósitos en curso + Proveedor + Spark mediante Breez + Sin límite + Este saldo se guarda en Spark, gestionado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable en cadena Gasto + Tus ahorros no cubren esta transferencia y sus comisiones. Prueba con una cantidad menor. + Ahorros Insuficientes La cantidad que puedes transferir a tu saldo de gasto está actualmente limitada a ₿ {amount}. Máximo del saldo de gasto + Transfiera más de ₿ {amount} para cubrir las comisiones de añadirlo a su saldo para gastos. + Importe demasiado bajo 25% + Transferir\n<accent>a gasto</accent> A gasto + 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 han cambiado + Listo en unos minutos + Tras 3 confirmaciones, unos 30 minutos Comisión de servicio Comisión de red + Bitkit no pudo obtener las comisiones actuales de esta transferencia. Compruebe su conexión e inténtelo de nuevo. + Comisiones no disponibles + Comisión de gastos Total + hasta + 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 + Instantáneo paga al proveedor para añadir su transferencia a su saldo para gastos antes de que se confirme, normalmente en unos minutos.\n\nMás tarde espera 3 confirmaciones, unos 30 minutos, con una comisión menor. + Transfer\n<accent>speed</accent> Estatus Soporte Reserva total de canales ancla @@ -232,10 +276,16 @@ Por favor,\n<accent>confirme</accent> Transferir fondos Deslizar para transferir + Abrir Trezor Connect 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). Por favor, espere a que Bitkit se conecte a la red bitcoin (±10 segundos). Saldos del monedero @@ -546,6 +596,7 @@ Contactos Perfil Ajustes + Datos de salida de Spark Etiquetas Registro de Transacciones Impulsos y transferencias @@ -1231,11 +1282,16 @@ Comisión de red Recibirás Comisión de servicio + Comisión de Spark 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> + Tu saldo de Spark no se ha podido pasar a ahorros y se queda en tu saldo de gastos. + Tu saldo de Spark no se ha podido pasar a ahorros y se queda en tu saldo de gastos. Tus conexiones se están pasando a ahorros. <accent>Conectando</accent>\na la red 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. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b9c24c4522..fa41c6b382 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -133,6 +133,7 @@ No se pueden transferir los fondos de vuelta a ahorros. Por favor, inténtalo de nuevo. Usar Otro Monedero Transferir desde billetera HW + La forma más fácil es usar tus ahorros de Bitkit o enviar bitcoin desde un monedero diferente. Configuración Manual Capacidad de entrada HTLC de entrada máximo @@ -141,6 +142,9 @@ Altura de archivo del monitor de canal 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 Hora de sincronización del monedero Lightning Hora de sincronización del monedero onchain Vencimiento del pedido @@ -154,24 +158,64 @@ 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 + Spark aún no ha visto tu transferencia. Se añade tras 3 confirmaciones; puedes salir de esta pantalla. + Pagar ₿ {amount} + Instantáneo ahora cuesta ₿ {amount}. Págalo o espera 3 confirmaciones, unos 30 minutos, con una comisión menor. + Añadiendo a gastos + Visto por Spark + Transferencia enviada + Instantáneo no está disponible para esta transferencia. Spark la añade a tu saldo para gastos tras 3 confirmaciones, unos 30 minutos. + Continuar Usando Bitkit + Spark + Comisión de reclamo + %1$d/3 confirmaciones + Depósito + Spark lo está añadiendo a tu saldo de gastos. + Esperando a que Spark vea tu transferencia. + Spark lo añade a tu saldo de gastos tras {standard} confirmaciones, en unos {minutes} minutos. + Esperar + Depósitos reclamados + Depósitos en curso + Proveedor + Spark mediante Breez + Sin límite + Este saldo se guarda en Spark, operado por Lightspark, Breez y Flashnet. Está seguro mientras al menos uno de ellos actúe con honestidad. Pueden pausar el acceso, y salir sin ellos requiere una salida on-chain. Gastable onchain La cantidad que puedes transferir a tu saldo de gastos está actualmente limitada a ₿ {amount}. Máximo del Saldo de Gastos + Transfiere más de ₿ {amount} para cubrir las comisiones de añadirlo a tu saldo para gastos. + Importe demasiado bajo + Transferir\n<accent>a gastos</accent> A gastos + 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 han cambiado + Listo en unos minutos + Tras 3 confirmaciones, unos 30 minutos Reserva total de canales ancla Total Lightning Total onchain TRANSFERENCIA EN PROGRESO + 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> Saldo de gastos Rellene su <accent>saldo de gastos</accent> Transferir desde Ahorros Transferir fondos Por favor,\n<accent>confirme</accent> Deslizar para transferir + Abrir Trezor Connect 25% Comisión de red + Bitkit no pudo obtener las comisiones actuales de esta transferencia. Comprueba tu conexión e inténtalo de nuevo. + Comisiones no disponibles + Comisión de gastos Comisión de servicio Total + hasta + 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 gastos</accent> Transferir\n<accent>a ahorros</accent> Empezar Transferir todo @@ -182,7 +226,9 @@ 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 @@ -190,8 +236,12 @@ Escanear QR Error en la Configuración de Pago Instántaneo Gasto + Sus ahorros no cubren esta transferencia y sus comisiones. Pruebe con una cantidad menor. + Ahorros Insuficientes Ahorro Saldo para gastos + Instantáneo paga al proveedor para añadir tu transferencia a tu saldo para gastos antes de que se confirme, normalmente en unos minutos.\n\nMás tarde espera 3 confirmaciones, unos 30 minutos, con una comisión menor. + Transfer\n<accent>speed</accent> Capacidad de recepción Nodo Lightning ID del nodo LDK @@ -573,6 +623,7 @@ Registro de Conexiones Registro de Transacciones Ajustes + Datos de salida de Spark Widgets Etiquetas Perfil @@ -1230,11 +1281,16 @@ Comisión de red Recibirá Comisión de servicio + Comisión de Spark 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> + Tu saldo de Spark no se ha podido pasar a ahorros y se queda en tu saldo de gastos. + Tu saldo de Spark no se ha podido pasar a ahorros y se queda en tu saldo de gastos. Tus conexiones se están pasando a ahorros. <accent>Conectando</accent>\na la red 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. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 504d48a804..205a538624 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -100,20 +100,50 @@ Dépend des frais Utiliser un autre portefeuille Transfert depuis le portefeuille HW + Le plus simple est d\'utiliser vos économies Bitkit ou d\'envoyer des bitcoins depuis un autre portefeuille. + Spark + Frais de réclamation + %1$d/3 confirmations + Dépôt + Spark l\'ajoute à votre solde de dépenses. + En attente que Spark voie votre transfert. + Spark l\'ajoute à votre solde de dépenses après {standard} confirmations, dans environ {minutes} minutes. + Attendre + Dépôts réclamés + Dépôts en cours + Fournisseur + Spark via Breez + Sans limite + Ce solde est détenu sur Spark, géré par Lightspark, Breez et Flashnet. Il est en sécurité tant qu’au moins l’un d’eux agit honnêtement. Ils peuvent suspendre l’accès, et en sortir sans eux nécessite une sortie on-chain. Solde Dépenses Financez votre <accent>solde Dépenses</accent> Transfert depuis l\'épargne Configuration manuelle + Transfert \n<accent>Dépenses</accent> Transfert de fonds Veuillez\n<accent>confirmer</accent> Glissez pour transférer + Ouvrir Trezor Connect 25% Seuil Maximum Épargne + Transférez plus de ₿ {amount} pour couvrir les frais d\'ajout à votre solde de dépenses. + Montant trop faible Le montant que vous pouvez transférer vers votre solde de dépenses est actuellement limité à ₿ {amount}. Frais de réseau + Bitkit n\'a pas pu obtenir les frais actuels de ce transfert. Vérifiez votre connexion et réessayez. + Frais indisponibles + Frais de dépense Frais de service Vers Dépenses + 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. + Frais modifiés + Prêt en quelques minutes + Après 3 confirmations, environ 30 minutes Total + jusqu\'à + Commencer + 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. + Transfert\n<accent>aux dépenses</accent> 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 @@ -131,7 +161,9 @@ 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>à l\'épargne</accent> + Fonds transférés \n<accent>vers Dépenses</accent> Vos fonds ont été transférés. Vous pouvez utiliser ces fonds immédiatement. + Vos fonds ont été transférés et votre solde Dépenses est prêt à être utilisé. Connexion Lightning <accent>Configuration manuelle</accent> Vous pouvez utiliser un nœud externe pour ouvrir manuellement une connexion Lightning. Saisissez les détails du nœud pour continuer. @@ -147,16 +179,31 @@ Échec de la configuration des paiements instantanés Une erreur s\'est produite lors de l\'établissement de votre solde du compte courant. {raw} Dépenser + Votre épargne ne couvre pas ce transfert et ses frais. Essayez un montant plus petit. + Épargne insuffisante Épargner Solde Dépenses + Instantané paie le fournisseur pour ajouter votre transfert à votre solde de dépenses avant sa confirmation, généralement en quelques minutes.\n\nPlus tard attend 3 confirmations, environ 30 minutes, pour des frais moins élevés. + Transfer\n<accent>speed</accent> Capacité de réception Heure de l\'instantané Rapid Gossip Sync Heure de synchronisation des scores de routage + Spark n\'a pas encore vu votre transfert. Il est ajouté après 3 confirmations ; vous pouvez quitter cet écran. + Payer ₿ {amount} + Instantané coûte maintenant ₿ {amount}. Payez-le, ou attendez 3 confirmations, environ 30 minutes, pour des frais moins élevés. + Ajout aux dépenses + Vu par Spark + Transfert envoyé + Instantané n\'est pas disponible pour ce transfert. Spark l\'ajoute à votre solde de dépenses après 3 confirmations, environ 30 minutes. + Continuer à utiliser Bitkit Dépensable Onchain Réserve totale des canaux Anchor Total Lightning Total Onchain TRANSFERT EN COURS + Commencer + Alimentez votre solde de dépenses pour profiter de transactions instantanées et bon marché avec vos amis, votre famille et les commerçants. + Dépenses \n<accent>Solde</accent> Soldes du portefeuille Nœud Lightning ID du nœud LDK @@ -209,6 +256,9 @@ Hauteur d\'archivage du moniteur de canal Limite HTLC sortant suivant HTLC sortant min suivant + Avant de pouvoir transférer des fonds depuis votre solde d\'épargne, vous devez envoyer des bitcoins à votre portefeuille Bitkit. + Approvisionner le portefeuille + Pas de fonds disponibles Support Fermer la connexion Échec de transfer  @@ -587,6 +637,7 @@ Journal des transactions Boosts et transferts Réglages + Données de sortie Spark Widgets Tags Profil @@ -1233,11 +1284,16 @@ Frais de réseau Vous recevrez Frais de service + Frais Spark 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 solde Spark n\'a pas pu être transféré vers l\'épargne et reste dans votre solde de dépenses. + Votre solde Spark n\'a pas pu être transféré vers l\'épargne et reste dans votre solde de dépenses. Vos connexions sont en cours de transfert vers l\'épargne. <accent>Connexion</accent>\nau réseau 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. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index bbe3915683..f46eaf89c8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -163,6 +163,7 @@ 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> Configurazione Manuale Capacita\' in Entrata @@ -173,6 +174,9 @@ 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 @@ -211,16 +215,56 @@ 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 + Spark non ha ancora visto il tuo trasferimento. Viene aggiunto dopo 3 conferme; puoi lasciare questa schermata. + Paga ₿ {amount} + Istantanea ora costa ₿ {amount}. Pagala, oppure attendi 3 conferme, circa 30 minuti, con una commissione più bassa. + Aggiunta alle spese + Visto da Spark + Trasferimento inviato + Istantanea non è disponibile per questo trasferimento. Spark lo aggiunge al tuo conto di spesa dopo 3 conferme, circa 30 minuti. + Continua ad utilizzare Bitkit + Spark + Commissione di riscatto + %1$d/3 conferme + Deposito + Spark lo sta aggiungendo al tuo saldo di spesa. + In attesa che Spark veda il tuo trasferimento. + Spark lo aggiunge al tuo saldo di spesa dopo {standard} conferme, tra circa {minutes} minuti. + Attendi + Depositi riscattati + Depositi in corso + Fornitore + Spark tramite Breez + Nessun limite + Questo saldo è custodito su Spark, gestito da Lightspark, Breez e Flashnet. È al sicuro finché almeno uno di loro agisce onestamente. Possono sospendere l’accesso e uscire senza di loro richiede un’uscita on-chain. Spendibile Onchain Spesa + I tuoi risparmi non coprono questo trasferimento e le relative commissioni. Prova un importo minore. + Risparmi insufficienti L\'importo che puoi trasferire al tuo conto di spesa e\' attualmente limitato a ₿ {amount}. Massimo Conto di Spesa + Trasferisci più di ₿ {amount}, così copre le commissioni per aggiungerlo al tuo conto di spesa. + Importo troppo basso 25% + Trasferisci\n<accent>alle spese</accent> Alle spese + 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. + Commissioni cambiate + Pronto in pochi minuti + Dopo 3 conferme, circa 30 minuti Commissioni servizio Commissioni rete + Bitkit non è riuscito a ottenere le commissioni attuali per questo trasferimento. Controlla la connessione e riprova. + Commissioni non disponibili + Commissione di spesa Totale + fino a + 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 + Istantanea paga il fornitore per aggiungere il tuo trasferimento al conto di spesa prima della conferma, di solito in pochi minuti.\n\nPiù tardi attende 3 conferme, circa 30 minuti, con una commissione più bassa. + Transfer\n<accent>speed</accent> Stato Supporto Riserva Totale Canali Anchor @@ -231,10 +275,16 @@ Per favore\n<accent>conferma</accent> Trasferimento Fondi Swipe Per Trasferire + Apri Trezor Connect 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). Attendi che Bitkit si connetta alla rete bitcoin (±10 secondi). Saldi Wallet @@ -545,6 +595,7 @@ Contatti Profilo Impostazioni + Dati di uscita Spark Tag Log delle transazioni Boost e Trasferimenti @@ -1236,11 +1287,16 @@ Commissione di rete Riceverai Commissione di servizio + Commissione Spark 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> + Il saldo Spark non è stato spostato nei risparmi e resta nel saldo di spesa. + Il saldo Spark non è stato spostato nei risparmi e resta nel saldo di spesa. Le tue connessioni stanno passando ai risparmi. <accent>Connessione</accent>\nalla rete 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. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 9e476721ba..fc7c27129e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -162,6 +162,7 @@ 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> Handmatige configuratie Inkomende capaciteit @@ -172,6 +173,9 @@ 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 @@ -210,16 +214,56 @@ Even geduld, je geld wordt overgeboekt. Dit duurt <accent>±10 seconden.</accent> Geld\n<accent>in overboeking</accent> Pathfinding scores synchronisatietijd + Spark heeft je overboeking nog niet gezien. Hij wordt toegevoegd na 3 bevestigingen; je kunt dit scherm verlaten. + Betaal ₿ {amount} + Direct kost nu ₿ {amount}. Betaal het, of wacht op 3 bevestigingen, ongeveer 30 minuten, tegen lagere kosten. + Toevoegen aan besteding + Gezien door Spark + Overboeking verzonden + Direct is niet beschikbaar voor deze overboeking. Spark voegt hem na 3 bevestigingen, ongeveer 30 minuten, toe aan je bestedingssaldo. + Doorgaan met Bitkit + Spark + Claimkosten + %1$d/3 bevestigingen + Storting + Spark voegt het toe aan je bestedingssaldo. + Wachten tot Spark je overboeking ziet. + Spark voegt het na {standard} bevestigingen toe aan je bestedingssaldo, over ongeveer {minutes} minuten. + Wachten + Geclaimde stortingen + Lopende stortingen + Aanbieder + Spark via Breez + Geen limiet + Dit saldo staat op Spark, beheerd door Lightspark, Breez en Flashnet. Het is veilig zolang ten minste één van hen eerlijk handelt. Zij kunnen de toegang pauzeren, en vertrekken zonder hen vereist een on-chain exit. Besteedbaar onchain Bestedingssaldo + Uw spaargeld is niet voldoende voor deze overboeking en de vergoedingen. Probeer een kleiner bedrag. + Onvoldoende spaargeld Het bedrag dat je kunt overboeken naar je bestedingssaldo is momenteel beperkt tot ₿ {amount}. Maximum bestedingssaldo + Boek meer dan ₿ {amount} over, zodat de kosten om het aan je bestedingssaldo toe te voegen gedekt zijn. + Bedrag te klein 25% + Overboeken\n<accent>naar bestedingssaldo</accent> Naar bestedingssaldo + De vergoedingen zijn {amount} hoger, omdat de vergoedingstarieven of uw spaargeld zijn gewijzigd terwijl u op dit scherm was. Veeg opnieuw om te bevestigen. + Vergoedingen gewijzigd + Klaar binnen enkele minuten + Na 3 bevestigingen, ongeveer 30 minuten Servicevergoedingen Netwerkvergoedingen + Bitkit kon de huidige kosten voor deze overboeking niet ophalen. Controleer je verbinding en probeer het opnieuw. + Kosten niet beschikbaar + Bestedingskosten Totaal + tot + 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 + Direct betaalt de aanbieder om je overboeking al vóór de bevestiging aan je bestedingssaldo toe te voegen, meestal binnen enkele minuten.\n\nLater wacht op 3 bevestigingen, ongeveer 30 minuten, tegen lagere kosten. + Transfer\n<accent>speed</accent> Status Ondersteuning Totale ankerkanalen reserve @@ -230,10 +274,16 @@ Graag\n<accent>bevestigen</accent> Geld overboeken Veeg om over te boeken + Trezor Connect openen 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). Wacht tot Bitkit verbinding maakt met het bitcoinnetwerk (±10 seconden). Wallet-saldi @@ -543,6 +593,7 @@ Contacten Profiel Instellingen + Spark-exitgegevens Tags Transactielog Boosts & overboekingen @@ -1232,11 +1283,16 @@ Netwerkvergoeding U ontvangt Servicevergoeding + Spark-vergoeding 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> + Je Spark-saldo kon niet naar sparen en blijft in je bestedingssaldo. + Je Spark-saldo kon niet naar sparen en blijft in je bestedingssaldo. Je verbindingen gaan naar sparen. <accent>Verbinden</accent>\nmet netwerk 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. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index db3199891a..61316f843c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -102,20 +102,50 @@ Zależy od opłaty Użyj innego portfela Prześlij z portfela HW + Najłatwiejszą opcją jest użycie oszczędności Bitkit lub wysłanie bitcoinów z innego portfela. + Spark + Opłata za odbiór + %1$d/3 potwierdzeń + Wpłata + Spark dodaje je do Twojego salda wydatków. + Czekamy, aż Spark zobaczy Twój przelew. + Spark doda je do Twojego salda wydatków po {standard} potwierdzeniach, za około {minutes} minut. + Czekaj + Odebrane wpłaty + Wpłaty w toku + Dostawca + Spark przez Breez + Bez limitu + To saldo jest przechowywane na Spark, prowadzonym przez Lightspark, Breez i Flashnet. Jest bezpieczne, dopóki co najmniej jeden z nich działa uczciwie. Mogą wstrzymać dostęp, a wyjście bez nich wymaga wyjścia on-chain. Saldo wydatków Zasil swoje <accent>saldo wydatków</accent> Prześlij z Oszczędności Konfiguracja ręczna + Prześlij \n<accent>na saldo wydatków</accent> Prześlij środki Proszę \n<accent>potwierdzić</accent> Przesuń, aby przesłać + Otwórz Trezor Connect 25% Maksymalne saldo wydatków + Prześlij więcej niż ₿ {amount}, aby pokryć opłaty za dodanie do salda wydatków. + Kwota zbyt mała Kwota, którą można przelać na saldo wydatków, jest obecnie ograniczona do ₿ {amount}. Opłaty sieciowe + Bitkit nie mógł pobrać aktualnych opłat za ten przelew. Sprawdź połączenie i spróbuj ponownie. + Opłaty niedostępne + Opłata za wydatki Opłaty serwisowe Do salda wydatków + 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ć. + Opłaty się zmieniły + Gotowe w kilka minut + Po 3 potwierdzeniach, około 30 minut Razem + do + Rozpocznij + Przelewaj środki na swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami. + Prześlij \n<accent>na saldo wydatków</accent> 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 @@ -133,7 +163,9 @@ Ś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 oszczędności</accent> + Środki przeniesione\n<accent>na saldo wydatków</accent> Środki zostały przelane i są gotowe do natychmiastowego użycia. + Środki zostały przeniesione i saldo wydatków jest gotowe do użycia. Połączenie z Lightning <accent>Konfiguracja ręczna</accent> Do ręcznego otwarcia połączenia Lightning można użyć węzła zewnętrznego. Proszę wprowadzić szczegóły węzła, aby kontynuować. @@ -149,8 +181,12 @@ Konfiguracja płatności natychmiastowych nie powiodła się Wystąpił błąd podczas konfigurowania salda natychmiastowego. {raw} Wydatki + Twoje oszczędności nie pokryją tego transferu i jego opłat. Spróbuj mniejszej kwoty. + Niewystarczające oszczędności Oszczędności Saldo wydatków + Natychmiast płaci dostawcy za dodanie przelewu do salda wydatków jeszcze przed potwierdzeniem, zwykle w kilka minut.\n\nPóźniej czeka na 3 potwierdzenia, około 30 minut, z niższą opłatą. + Transfer\n<accent>speed</accent> Zdolność odbioru Węzeł Lightning Identyfikator węzła LDK @@ -194,6 +230,9 @@ Wysokość archiwalnego monitora kanału Limit następnej wychodzącej HTLC Min. następnej wychodzącej HTLC + Zanim będzie można przelać środki z salda oszczędności, należy wysłać bitcoiny do portfela Bitkit. + Doładuj portfel + Brak dostępnych środków Inne Jest użyteczny Otwarto @@ -233,11 +272,22 @@ Połączenie nieaktywne Czas snapshotu Rapid Gossip Sync Czas synchronizacji wyników ścieżek + Spark jeszcze nie widzi Twojego przelewu. Zostanie dodany po 3 potwierdzeniach; możesz opuścić ten ekran. + Zapłać ₿ {amount} + Natychmiast kosztuje teraz ₿ {amount}. Zapłać albo poczekaj na 3 potwierdzenia, około 30 minut, z niższą opłatą. + Dodawanie do wydatków + Widziany przez Spark + Przelew wysłany + Natychmiast nie jest dostępne dla tego przelewu. Spark doda go do Twojego salda wydatków po 3 potwierdzeniach, około 30 minut. + Kontunuuj korzystanie z Bitkit\'a Dostępne Onchain Całkowita rezerwa kanałów anchor Całkowite Lightning Całkowite Onchain TRANSFER W TOKU + Rozpocznij + Zasil swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami. + Saldo \n<accent>Wydatków</accent> Saldo portfela Proszę poczekać, aż Bitkit połączy się z siecią płatności (±10 sekund). Proszę poczekać, aż Bitkit połączy się z siecią bitcoin (±10 sekund). @@ -586,6 +636,7 @@ Log transakcji Przyśpieszenia i transfery Ustawienia + Dane wyjścia ze Spark Widgety Tagi Profil @@ -1236,11 +1287,16 @@ Opłata sieciowa Otrzymasz Opłata serwisowa + Opłata Spark 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> + Nie udało się przenieść salda Spark do oszczędności; pozostaje w saldzie wydatków. + Nie udało się przenieść salda Spark do oszczędności; pozostaje w saldzie wydatków. Twoje połączenia są przenoszone do oszczędności. <accent>Łączenie</accent>\nz siecią 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ą. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 47626cd119..3e7b7deb6b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -166,6 +166,7 @@ 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> Setup Manual Capacidade de Entrada @@ -176,6 +177,9 @@ 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 @@ -214,16 +218,56 @@ 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 + A Spark ainda não viu sua transferência. Ela é adicionada após 3 confirmações; você pode sair desta tela. + Pagar ₿ {amount} + Instantâneo agora custa ₿ {amount}. Pague ou aguarde 3 confirmações, cerca de 30 minutos, com uma taxa menor. + Adicionando aos gastos + Vista pela Spark + Transferência enviada + Instantâneo não está disponível para esta transferência. A Spark a adiciona ao seu saldo de gastos após 3 confirmações, cerca de 30 minutos. + Continuar Usando o Bitkit + Spark + Taxa de resgate + %1$d/3 confirmações + Depósito + A Spark está adicionando ao seu saldo de gastos. + Aguardando a Spark ver sua transferência. + A Spark adiciona ao seu saldo de gastos após {standard} confirmações, em cerca de {minutes} minutos. + Esperar + Depósitos resgatados + Depósitos em andamento + Provedor + Spark via Breez + Sem limite + Este saldo fica na Spark, operada pela Lightspark, Breez e Flashnet. Ele está seguro enquanto pelo menos um deles agir com honestidade. Eles podem pausar o acesso, e sair sem eles exige uma saída on-chain. Disponível Onchain Gastos + Sua poupança não cobre esta transferência e suas taxas. Tente um valor menor. + Saldo de Poupança Insuficiente O valor que você pode transferir para seu saldo de gastos está atualmente limitado a ₿ {amount}. Saldo de gastos Máximo + Transfira mais de ₿ {amount} para cobrir as taxas de adicioná-la ao seu saldo de gastos. + Valor muito baixo 25% + Transferir\n<accent>para gastos</accent> Para gastos + 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. + Taxas alteradas + Pronto em poucos minutos + Após 3 confirmações, cerca de 30 minutos Taxa de serviço Taxa da rede + O Bitkit não conseguiu obter as taxas atuais desta transferência. Verifique sua conexão e tente novamente. + Taxas indisponíveis + Taxa de gastos Total + até + 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 + Instantâneo paga ao provedor para adicionar sua transferência ao saldo de gastos antes de ela ser confirmada, normalmente em poucos minutos.\n\nDepois aguarda 3 confirmações, cerca de 30 minutos, com uma taxa menor. + Transfer\n<accent>speed</accent> Status Suporte Reserva Total de Canais Anchor @@ -234,10 +278,16 @@ Por favor\n<accent>confirme</accent> Transf. fundos Deslizar Para Transferir + Abrir Trezor Connect 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). Por favor, espere que a Bitkit se conecte à rede bitcoin (±10 segundos). Saldos da Carteira @@ -550,6 +600,7 @@ Contatos Perfil Ajustes + Dados de saída do Spark Tags Histórico de transações Boosts e transferências @@ -1238,11 +1289,16 @@ Taxa de rede Você receberá Taxa de serviço + Taxa do Spark 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> + Não foi possível mover seu saldo do Spark para a poupança; ele continua no saldo de gastos. + Não foi possível mover seu saldo do Spark para a poupança; ele continua no saldo de gastos. Suas conexões estão sendo movidas para a poupança. <accent>Conectando</accent>\nà rede 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. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1fece40c5e..81321c2947 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -97,20 +97,50 @@ Depende da taxa Transferir de Outra Carteira Transferir da carteira HW + A forma mais fácil é usar as suas poupanças Bitkit ou enviar bitcoin de uma carteira diferente. + Spark + Taxa de resgate + %1$d/3 confirmações + Depósito + A Spark está a adicioná-lo ao seu saldo de gastos. + A aguardar que a Spark veja a sua transferência. + A Spark adiciona-o ao seu saldo de gastos após {standard} confirmações, dentro de cerca de {minutes} minutos. + Esperar + Depósitos resgatados + Depósitos em curso + Fornecedor + Spark via Breez + Sem limite + Este saldo é mantido na Spark, operada pela Lightspark, Breez e Flashnet. Está seguro enquanto pelo menos um deles agir com honestidade. Podem suspender o acesso, e sair sem eles exige uma saída on-chain. Saldo de Gastos Transfira para seu <accent>saldo de gastos</accent> Transferir do Saldo de Poupança Setup Manual + Transferir\n<accent>para gastos</accent> Transf. fundos Por favor\n<accent>confirme</accent> Deslizar Para Transferir + Abrir Trezor Connect 25% Saldo de gastos Máximo + Transfira mais de ₿ {amount} para cobrir as taxas de a adicionar ao seu saldo de gastos. + Montante demasiado baixo O valor que você pode transferir para seu saldo de gastos está atualmente limitado a ₿ {amount}. Taxa da rede + A Bitkit não conseguiu obter as taxas atuais desta transferência. Verifique a sua ligação e tente novamente. + Taxas indisponíveis + Taxa de gastos Taxa de serviço Para gastos + 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 mudaram + Pronto em poucos minutos + Após 3 confirmações, cerca de 30 minutos Total + até + 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> 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 @@ -128,7 +158,9 @@ 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 poupança</accent> + Fundos transferidos\n<accent>para gastos</accent> 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. Conexões Lightning <accent>Setup manual</accent> Você pode usar um nó externo para abrir manualmente uma conexão Lightning. Digite os detalhes do nó para continuar. @@ -144,8 +176,12 @@ Setup dos Pagamentos Instantâneos falhou Ocorreu um erro ao configurar seu saldo de gastos. {raw} Gastos + A sua poupança não cobre esta transferência e as respetivas taxas. Tente um montante menor. + Saldo de Poupança Insuficiente Poupança Saldo de gastos + Instantâneo paga ao fornecedor para adicionar a sua transferência ao saldo de gastos antes de ser confirmada, normalmente em poucos minutos.\n\nDepois aguarda 3 confirmações, cerca de 30 minutos, com uma taxa mais baixa. + Transfer\n<accent>speed</accent> Capacidade de recebimento Nó Lightning ID do LDK Node @@ -226,14 +262,28 @@ Tempo de Sinc. da Carteira Lightning Tempo do Snapshot Rapid Gossip Sync Tempo de Sinc. das Pontuações de Pathfinding + A Spark ainda não viu a sua transferência. É adicionada após 3 confirmações; pode sair deste ecrã. + Pagar ₿ {amount} + Instantâneo custa agora ₿ {amount}. Pague-o ou aguarde 3 confirmações, cerca de 30 minutos, com uma taxa mais baixa. + A adicionar aos gastos + Vista pela Spark + Transferência enviada + Instantâneo não está disponível para esta transferência. A Spark adiciona-a ao seu saldo de gastos após 3 confirmações, cerca de 30 minutos. + Continuar Usando o Bitkit Tempo de Atualização do Cache de Taxa Capacidade de Entrada Limite do Próximo HTLC de Saída Mínimo do Próximo HTLC de Saída + Antes de transferir fundos de seu saldo de poupança, receba bitcoin na sua carteira Bitkit. + Financiar Carteira + Não há Fundos Disponíveis Máximo do HTLC de Entrada Mínimo do HTLC de Entrada Altura do Arquivo do Monitor de Canal 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> Começar Configure o seu saldo de despesas para desfrutar de transações instantâneas e económicas com amigos, familiares e comerciantes. Ativar\nLightning\n<accent>pagamentos</accent> @@ -563,6 +613,7 @@ Histórico de transações Boosts e transferências Ajustes + Dados de saída do Spark Widgets Tags Perfil @@ -1207,11 +1258,16 @@ Taxa de rede Vai receber Taxa de serviço + Taxa do Spark 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> + Não foi possível mover o saldo do Spark para a poupança; continua no saldo de gastos. + Não foi possível mover o saldo do Spark para a poupança; continua no saldo de gastos. As suas ligações estão a ser movidas para a poupança. <accent>A ligar</accent>\nà rede 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. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c59eb8f103..3f8d38a426 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -174,6 +174,7 @@ Использовать Другой Кошелёк Перевести с аппаратного кошелька Баланс Расходов + Самый простой вариант — использовать ваши сбережения Bitkit или отправить биткоин с другого кошелька. Пополните свой <accent>Баланс Расходов</accent> Ручная Настройка Входящая ёмкость @@ -184,6 +185,9 @@ Архивная высота мониторинга канала Лимит след. исходящего HTLC Мин. след. исходящего HTLC + Прежде чем вы сможете перевести средства со своего баланса сбережений, вам нужно отправить биткойны в ваш кошелёк Bitkit. + Пополнить кошелёк + Недостаточно Средств ID Узла LDK Lightning Узел Время синхронизации Lightning кошелька @@ -222,16 +226,56 @@ Пожалуйста, подождите, ваш перевод средств выполняется. Это займёт <accent>±10 секунд.</accent> Средства\n<accent>в Процессе Перевода</accent> Время синхронизации оценок маршрутизации + Spark ещё не увидел ваш перевод. Он будет зачислен после 3 подтверждений; можно покинуть этот экран. + Оплатить ₿ {amount} + Мгновенно теперь стоит ₿ {amount}. Оплатите или дождитесь 3 подтверждений, около 30 минут, с меньшей комиссией. + Зачисление на расходы + Виден Spark + Перевод отправлен + Мгновенно недоступно для этого перевода. Spark зачислит его на баланс расходов после 3 подтверждений, примерно через 30 минут. + Продолжить Использование Bitkit + Spark + Комиссия за зачисление + %1$d/3 подтверждений + Депозит + Spark зачисляет их на ваш расходный баланс. + Ждём, пока Spark увидит ваш перевод. + Spark зачислит их на ваш расходный баланс после {standard} подтверждений, примерно через {minutes} минут. + Ждать + Зачисленные депозиты + Депозиты в обработке + Провайдер + Spark через Breez + Без ограничений + Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода. Доступно Onchain Расходы + Ваших сбережений не хватает на этот перевод и его комиссии. Попробуйте меньшую сумму. + Недостаточно Сбережений Сумма, которую вы можете перевести на баланс расходов, в настоящее время ограничена ₿ {amount}. Максимум Баланса Расходов + Переведите больше ₿ {amount}, чтобы покрыть комиссии за зачисление на баланс расходов. + Слишком маленькая сумма 25% + Перевод\n<accent>на Расходы</accent> На Баланс Расходов + Комиссии выросли на {amount}, потому что ставки комиссий или баланс сбережений изменились, пока вы были на этом экране. Проведите ещё раз для подтверждения. + Комиссии изменились + Готово через несколько минут + После 3 подтверждений, около 30 минут Сервисные Комиссии Сетевые Комиссии + Bitkit не смог получить текущие комиссии за этот перевод. Проверьте соединение и попробуйте снова. + Комиссии недоступны + Комиссия расходов Итого + до + Начать + Переведите средства на свой баланс расходов, чтобы наслаждаться мгновенными и недорогими транзакциями с друзьями, семьёй и продавцами. + Перевод\n<accent>на Расходы</accent> Баланс Расходов + Мгновенно платит провайдеру, чтобы зачислить перевод на баланс расходов до подтверждения, обычно за несколько минут.\n\nПозже ждёт 3 подтверждений, около 30 минут, с меньшей комиссией. + Transfer\n<accent>speed</accent> Статус Поддержка Общий резерв Anchor каналов @@ -242,10 +286,16 @@ Пожалуйста,\n<accent>Подтвердите</accent> Перевод Средств Проведите для Перевода + Открыть Trezor Connect ПЕРЕВОД В ПРОЦЕССЕ + Начать + Пополните свой баланс расходов, чтобы наслаждаться мгновенными и недорогими транзакциями с друзьями, семьёй и продавцами. + Расходы\n<accent>Баланс</accent> Перевод Выполнен Успешно Ваши средства переведены. Вы можете использовать эти средства немедленно. + Ваши средства переведены, и ваш баланс расходов готов к использованию. Средства Переведены\n<accent>в Сбережения</accent> + Средства Переведены\n<accent>на Расходы</accent> Пожалуйста, подождите, пока Bitkit подключается к платёжной сети (±10 секунд). Пожалуйста, подождите, пока Bitkit подключается к сети биткоин (±10 секунд). Балансы кошелька @@ -561,6 +611,7 @@ Контакты Профиль Настройки + Данные выхода из Spark Теги Журнал Транзакций Ускорения и Переводы @@ -1254,11 +1305,16 @@ Сетевая комиссия Вы получите Сервисная комиссия + Комиссия Spark Ваш перевод инициирован и <accent>проводится в блокчейне</accent>. Баланс сбережений обновится автоматически после завершения. Перевод\n<accent>в пути</accent> + Не удалось перевести баланс Spark в сбережения, он остаётся в балансе расходов. + Не удалось перевести баланс Spark в сбережения, он остаётся в балансе расходов. Ваши соединения переводятся в сбережения. <accent>Подключение</accent>\nк сети Переподключите аппаратное устройство: похоже, оно отключилось от телефона. Переподключите Аппаратное Устройство + Подпишите на\n<accent>устройстве</accent> + Транзакция\n<accent>подписана</accent> ПЕРЕВОД БУДЕТ ГОТОВ ЧЕРЕЗ %s Перевод недоступен Автоматические переводы на баланс расходов больше недоступны. Используйте собственный узел или настройте подключение к другому провайдеру. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0581f041af..2fb23120cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -311,6 +311,7 @@ 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> Manual Setup Inbound Capacity @@ -321,6 +322,9 @@ 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 @@ -355,6 +359,7 @@ Network fee You\'ll receive Service fee + Spark fee Transfer all Transfer\n<accent>interrupted</accent> Funds were not transferred yet. Bitkit will try to initiate the transfer during the next <accent>30 minutes</accent>. Please keep your app open. @@ -366,17 +371,60 @@ Funds\n<accent>in transfer</accent> 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> + Your Spark balance couldn\'t move to savings and stays in your spending balance. + Your Spark balance couldn\'t move to savings and stays in your spending balance. Your connections are moving to savings. Pathfinding Scores Sync Time + Spark hasn\'t seen your transfer yet. It\'s added after 3 confirmations; you can leave this screen. + Pay ₿ {amount} + Instant now costs ₿ {amount}. Pay it, or wait for 3 confirmations, about 30 minutes, at a lower fee. + Adding To Spending + Seen By Spark + Transfer Sent + Instant isn\'t available for this transfer. Spark adds it to your spending balance after 3 confirmations, about 30 minutes. + Continue Using Bitkit + Spark + Claim fee + %1$d/3 confirmations + Deposit + Spark is adding it to your spending balance. + Waiting for Spark to see your transfer. + Spark adds it to your spending balance after {standard} confirmations, about {minutes} minutes. + Wait + Claimed deposits + Deposits in progress + Provider + Spark via Breez + No limit + This balance is held on Spark, run by Lightspark, Breez and Flashnet. It is safe while at least one of them acts honestly. They can pause access, and leaving without them takes an on-chain exit. Spendable Onchain Spending + 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}. Spending Balance Maximum + Transfer more than ₿ {amount}, so it covers the fees to add it to your spending balance. + Amount Too Small 25% + Transfer\n<accent>to spending</accent> + ≈ To spending + Fees are {amount} higher because fee rates or your savings balance changed while you were on this screen. Swipe again to confirm. + Fees changed + Ready in a few minutes + After 3 confirmations, about 30 minutes Service fees Network fees + Bitkit couldn\'t get the current fees for this transfer. Check your connection and try again. + Fees Unavailable + Spending fee Total + up to + 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 + Instant pays the provider to add your transfer to your spending balance before it confirms, usually within a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, at a lower fee. + Transfer\n<accent>speed</accent> Status Support <accent>Connecting</accent>\nto network @@ -388,13 +436,21 @@ 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 + Get Started + Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. + Spending\n<accent>Balance</accent> TRANSFER READY IN %s 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 @@ -854,6 +910,7 @@ Contacts Profile Settings + Spark exit data Tags Transaction Log Boosts & Transfers diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index a76ca21675..110f3521fd 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -28,6 +28,7 @@ import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.SparkRepo import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import java.math.BigDecimal @@ -58,6 +59,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { private val settingsStore: SettingsStore = mock() private val clock: Clock = mock() private val backupRepo: BackupRepo = mock() + private val sparkRepo: SparkRepo = mock() private val migrationService: MigrationService = mock() private val isRestoring = MutableStateFlow(false) private val isShowingMigrationLoading = MutableStateFlow(false) @@ -74,6 +76,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { whenever(migrationService.isShowingMigrationLoading).thenReturn(isShowingMigrationLoading) whenever { migrationService.needsPostMigrationSync() }.thenReturn(false) whenever(clock.now()).thenReturn(NOW) + whenever { sparkRepo.markWithdrawalAsTransfer(any()) }.thenReturn(false) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenReturn( Result.success( ConvertedAmount( @@ -99,6 +102,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { currencyRepo = currencyRepo, settingsStore = settingsStore, ), + sparkRepo = sparkRepo, ) } @@ -187,6 +191,21 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { verify(activityRepo).markOnchainActivityAsSeen("txid456", WalletScope.default) } + @Test + fun `onchain receive from a spark withdrawal is a transfer with no received sheet`() = test { + val details = mock { + on { amountSats } doReturn 5000L + } + whenever(activityRepo.shouldShowReceivedSheet(any(), any())).thenReturn(true) + whenever(sparkRepo.markWithdrawalAsTransfer("txid456")).thenReturn(true) + + val result = sut(NotifyPaymentReceived.Command.Onchain(txid = "txid456", details = details)) + + assertTrue(result.getOrThrow() is NotifyPaymentReceived.Result.Skip) + verify(activityRepo).handleOnchainTransactionReceived("txid456", details) + verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) + } + @Test fun `onchain payment returns Skip when shouldShowReceivedSheet is false`() = test { val details = mock { diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 85a4e812de..66db95505f 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -38,6 +38,7 @@ import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -81,6 +82,7 @@ import to.bitkit.models.PaykitUsdtStateBackup import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 import to.bitkit.models.SettingsBackupV1 +import to.bitkit.models.SparkExitBackupV1 import to.bitkit.models.TransportType import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WalletScope @@ -138,6 +140,7 @@ class BackupRepoTest : BaseUnitTest() { private val paykitAllowanceStore = mock() private val keychain = mock() private val onchainSendAttemptStore = mock() + private val sparkRepo = mock() private val vssStoreIdProvider = mock() private val preActivityMetadataRepo = mock() private val lightningService = mock() @@ -218,6 +221,11 @@ class BackupRepoTest : BaseUnitTest() { }.thenReturn(Result.success(Unit)) whenever { vssBackupClient.listKeys() }.thenReturn(Result.success(emptyList())) + whenever { sparkRepo.exitBackup() }.thenReturn(Result.success(null)) + whenever { sparkRepo.restoreExitBackup(any(), any()) }.thenReturn(Result.success(Unit)) + whenever(sparkRepo.exitStateChanged).thenReturn(MutableSharedFlow()) + whenever(sparkRepo.state).thenReturn(MutableStateFlow(SparkState())) + sut = createSut() } @@ -251,6 +259,54 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `spark exit backup uploads the exported state and restores it before the wallet`() = test { + val backup = SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "{\"pedigrees\":[]}") + whenever(sparkRepo.exitBackup()).thenReturn(Result.success(backup)) + + sut.triggerBackup(BackupCategory.SPARK_EXIT).getOrThrow() + + val data = argumentCaptor() + verify(vssBackupClient).putObject(eq(BackupCategory.SPARK_EXIT.name), data.capture()) + whenever(vssBackupClient.getObject(BackupCategory.SPARK_EXIT.name)).thenReturn( + Result.success(VssItem(key = BackupCategory.SPARK_EXIT.name, value = data.firstValue, version = 1)) + ) + + sut.performFullRestoreFromLatestBackup().getOrThrow() + + inOrder(sparkRepo, vssBackupClient) { + verify(sparkRepo).restoreExitBackup(backup, 0) + verify(vssBackupClient).getObject(BackupCategory.WALLET.name) + } + } + + @Test + fun `spark exit backup uploads when Spark was ready before observing started`() = test { + val statuses = MutableStateFlow(mapOf(BackupCategory.SPARK_EXIT to BackupItemStatus(synced = 1, required = 1))) + stubBackupStatuses(statuses, CompletableDeferred(Unit)) {} + stubBackupObservers() + whenever(sparkRepo.state).thenReturn(MutableStateFlow(SparkState(isSupported = true, isReady = true))) + val backup = SparkExitBackupV1(createdAt = 1_000, network = "regtest", exitState = "state") + whenever(sparkRepo.exitBackup()).thenReturn(Result.success(backup)) + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(6_000) + runCurrent() + + verify(vssBackupClient).putObject(eq(BackupCategory.SPARK_EXIT.name), any()) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `spark exit backup uploads nothing while Spark is not connected`() = test { + sut.triggerBackup(BackupCategory.SPARK_EXIT).getOrThrow() + + verify(vssBackupClient, never()).putObject(eq(BackupCategory.SPARK_EXIT.name), any()) + } + @Test fun `wallet backup waits for submission and includes the latest pending state`() = test { val statuses = MutableStateFlow( @@ -3037,6 +3093,7 @@ class BackupRepoTest : BaseUnitTest() { keychain = keychain, onchainSendAttemptStore = onchainSendAttemptStore, lightningRepo = Provider { lightningRepo }, + sparkRepo = sparkRepo, vssStoreIdProvider = vssStoreIdProvider, preActivityMetadataRepo = preActivityMetadataRepo, lightningService = lightningService, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 18c097ea37..8e3c2ec285 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -89,6 +89,7 @@ import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest import to.bitkit.test.connectionRecoveryFixture +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.UrlValidator @@ -102,6 +103,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant @OptIn(kotlin.time.ExperimentalTime::class, ExperimentalCoroutinesApi::class) class LightningRepoTest : BaseUnitTest() { @@ -123,6 +125,7 @@ class LightningRepoTest : BaseUnitTest() { private lateinit var sut: LightningRepo private val lightningService = mock() + private val sparkRepo = idleSparkRepo() private val settingsStore = mock() private val coreService = mock() private val keychain = mock() @@ -164,6 +167,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) } @@ -229,6 +233,7 @@ class LightningRepoTest : BaseUnitTest() { activityRepo = mock(), hwWalletRepo = hardware, connectionRecovery = connectionRecoveryFixture(settingsStore), + sparkRepo = sparkRepo, ) } @@ -1098,6 +1103,50 @@ class LightningRepoTest : BaseUnitTest() { verify(lightningService, never()).send(any(), anyOrNull(), anyOrNull(), anyOrNull()) } + @Test + fun `pending wallet recovery prevents spark payment dispatch`() = test { + startNodeForTesting() + whenever(sparkRepo.canSend(1_000uL)).thenReturn(true) + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + var beforeSend = false + + val result = sut.payInvoice("invoice", 1_000uL, onBeforeSend = { + beforeSend = true + true + }) + + assertIs(result.exceptionOrNull()) + assertFalse(beforeSend) + verify(sparkRepo, never()).payInvoice(any(), anyOrNull(), anyOrNull(), anyOrNull()) + } + + @Test + fun `spark payment keeps the deadline and the fee limit`() = test { + startNodeForTesting() + whenever(sparkRepo.canSend(1_000uL)).thenReturn(true) + whenever(sparkRepo.payInvoice(any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Result.success("hash")) + val deadline = Instant.fromEpochSeconds(1_800_000_000) + + val result = sut.payInvoice("invoice", 1_000uL, deadline, maxTotalRoutingFeeMsat = 10_000uL) { true } + + assertEquals("hash", result.getOrThrow()) + verify(sparkRepo).payInvoice("invoice", 1_000uL, deadline, 10_000uL) + } + + @Test + fun `restore beginning during spark payment authorization prevents dispatch`() = test { + startNodeForTesting() + whenever(sparkRepo.canSend(1_000uL)).thenReturn(true) + + val result = sut.payInvoice("invoice", 1_000uL, onBeforeSend = { + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + true + }) + + assertIs(result.exceptionOrNull()) + verify(sparkRepo, never()).payInvoice(any(), anyOrNull(), anyOrNull(), anyOrNull()) + } + @Test fun `restore beginning during payment authorization prevents dispatch`() = test { startNodeForTesting() @@ -2085,6 +2134,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) whenever(coreService.activity).thenReturn(mock()) @@ -2510,6 +2560,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) val activityService = mock() @@ -2580,6 +2631,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) val activityService = mock() @@ -2661,6 +2713,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) val activityService = mock() @@ -3140,6 +3193,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = failingValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) sutWithFailingValidator.setInitNodeLifecycleState() whenever(lightningService.node).thenReturn(mock()) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowancePayerTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowancePayerTest.kt index 8e8cc711b9..cb79fdac3d 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowancePayerTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowancePayerTest.kt @@ -3,13 +3,17 @@ package to.bitkit.repositories import com.synonym.paykit.PaymentOutcome import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.models.WalletScope import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue +import breez_sdk_spark.PaymentStatus as SparkPaymentStatus class PaykitAllowancePayerTest : BaseUnitTest() { private val paymentProofRepo = mock() @@ -18,10 +22,43 @@ class PaykitAllowancePayerTest : BaseUnitTest() { private val requestId = PaykitPaymentRequestId(paymentRequestId = "req-1", counterparty = "payee") private val txid = "ab".repeat(32) + private val paymentHash = "cd".repeat(32) @Before fun setUp() = test { whenever(paymentProofRepo.hasVerifiedOnchainProof(any(), any())).thenReturn(false) + whenever(lightningRepo.listPaymentsOrNull()).thenReturn(emptyList()) + whenever(lightningRepo.canListSparkPayments()).thenReturn(true) + whenever(lightningRepo.sparkLightningSend(any())).thenReturn(SparkLightningSend.NotFound) + } + + @Test + fun `a lightning send the node does not know is read from spark`() = test { + val statuses = mapOf( + SparkPaymentStatus.COMPLETED to PaymentStatus.SUCCEEDED, + SparkPaymentStatus.PENDING to PaymentStatus.PENDING, + SparkPaymentStatus.FAILED to PaymentStatus.FAILED, + ) + for ((spark, node) in statuses) { + whenever(lightningRepo.sparkLightningSend(paymentHash)).thenReturn(SparkLightningSend.Found(spark, null)) + + assertEquals(node, sut.lightningPaymentStatus(paymentHash)) + } + } + + @Test + fun `a lightning send neither rail knows is one the wallet never made`() = test { + assertTrue(sut.canListLightningPayments()) + assertNull(sut.lightningPaymentStatus(paymentHash)) + } + + @Test + fun `a spark send is never read as unsent while spark cannot list its payments`() = test { + whenever(lightningRepo.canListSparkPayments()).thenReturn(false) + whenever(lightningRepo.sparkLightningSend(paymentHash)).thenReturn(SparkLightningSend.Unavailable) + + assertFalse(sut.canListLightningPayments()) + assertEquals(PaymentStatus.PENDING, sut.lightningPaymentStatus(paymentHash)) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 55fc7fc43e..b6869c1e77 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -63,6 +63,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { private val executorEvents = MutableSharedFlow(extraBufferCapacity = 8) private val nodeEvents = MutableSharedFlow(extraBufferCapacity = 8) private val lightningState = MutableStateFlow(LightningState()) + private val isSparkSynced = MutableStateFlow(false) private val terms = fixtures.terms() private val clock = object : Clock { override fun now(): Instant = PaykitAllowanceFixtures.now @@ -97,6 +98,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { whenever(executor.classifyAboveLimit(any(), any(), any())).thenReturn(false) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(lightningRepo.lightningState).thenReturn(lightningState) + whenever(lightningRepo.isSparkSynced).thenReturn(isSparkSynced) whenever(activityRepo.setContact(any(), any(), any(), any())).thenReturn(Result.success(Unit)) whenever(sdk.listAllowances(any())).thenAnswer { records } whenever(sdk.linkedPeers()).thenReturn(emptyList()) @@ -894,6 +896,18 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { verify(executor).settleOpenLightningAttempts() } + @Test + fun `spark syncing settles attempts left open while it could not list payments`() = test { + sut.activate(identity) + whenever(executor.activeIdentity).thenReturn(identity) + clearInvocations(executor) + + isSparkSynced.value = true + + verify(executor).recover(identity) + verify(executor).settleOpenLightningAttempts() + } + // endregion // region Helpers diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index c6f146bb78..9001d10569 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import android.content.Context +import breez_sdk_spark.SdkException import com.synonym.bitkitcore.BroadcastException import com.synonym.paykit.BillingPeriod import com.synonym.paykit.ConversionRate @@ -75,6 +76,7 @@ import to.bitkit.models.WalletScope import to.bitkit.services.CoreService import to.bitkit.services.PaykitSdkOperationLock import to.bitkit.services.PaykitSdkService +import to.bitkit.services.SparkError import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.wallets.send.HwSendRequest import to.bitkit.ui.screens.wallets.send.HwSendViewModel @@ -93,6 +95,7 @@ import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +import breez_sdk_spark.PaymentStatus as SparkPaymentStatus private val lenientJson = Json { ignoreUnknownKeys = true } @@ -812,6 +815,55 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `lightning proof settles from spark when its event was missed`() = test { + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever( + paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull(), isNull(), isNull(), any()) + ).thenReturn(record) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() + + for (pending in listOf(SparkLightningSend.Unavailable, SparkLightningSend.NotFound)) { + whenever(lightningRepo.sparkLightningSend(PAYMENT_HASH)).thenReturn(pending) + paymentProofRepo().reconcile() + assertEquals(PAYMENT_HASH, storedProofs.single().paymentIdentifier) + } + whenever(lightningRepo.sparkLightningSend(PAYMENT_HASH)) + .thenReturn(SparkLightningSend.Found(SparkPaymentStatus.COMPLETED, PREIMAGE)) + paymentProofRepo().reconcile() + + val proofCaptor = argumentCaptor() + verify(paykitSdkService).submitPaymentProof( + any(), any(), any(), any(), proofCaptor.capture(), isNull(), isNull(), isNull(), any(), + ) + assertEquals( + """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", + proofCaptor.firstValue, + ) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `lightning proof is removed when spark failed the send`() = test { + whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(lightningRepo.sparkLightningSend(PAYMENT_HASH)) + .thenReturn(SparkLightningSend.Found(SparkPaymentStatus.FAILED, null)) + val request = paymentRequest(MethodId.Bolt11.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.Bolt11.rawValue, "bitkit", PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue, "bitkit").getOrThrow() + + paymentProofRepo().reconcile() + + assertTrue(storedProofs.isEmpty()) + } + @Test fun `allowance proof is submitted with its allowance id`() = test { val record = paymentRequestRecord() @@ -1013,6 +1065,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { LdkError(NodeException.PersistenceFailed("io")), NodeException.DuplicatePayment("pending"), AppError("payment outcome unknown"), + SdkException.Generic("spark send timed out"), ) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) @@ -1064,6 +1117,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { NodeException.InvalidInvoice("invalid"), NodeException.InvalidAmount("invalid"), LdkError(NodeException.PaymentSendingFailed("no route")), + SparkError.SendRefused("Spark fee over the limit"), + to.bitkit.utils.AppError(SparkError.SendRefused("Spark could not prepare the payment")), ) for (error in errors) { val request = paymentRequest(MethodId.Bolt11.rawValue) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt index 9c9b18c55d..39fc8adc82 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAllowancePaymentTest.kt @@ -54,6 +54,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) private val cacheStore = mock() private val settingsStore = mock() private val lightningRepo = mock() + private val sparkRepo = mock() private val publicPaykitRepo = mock() private val coreService = mock() private val clock = mock() @@ -75,6 +76,8 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) } } PublicPaykitRepo.lightningRouteHintsValidator = { true } + whenever { sparkRepo.receivedLightningPaymentHashes() }.thenReturn(emptySet()) + whenever { sparkRepo.attemptedLightningPaymentHashes() }.thenReturn(emptySet()) sut = PrivatePaykitRepo( ioDispatcher = testDispatcher, @@ -84,6 +87,7 @@ class PrivatePaykitAllowancePaymentTest : BaseUnitTest(StandardTestDispatcher()) settingsStore = settingsStore, addressReservationRepo = mock(), lightningRepo = lightningRepo, + sparkRepo = sparkRepo, walletRepo = mock(), publicPaykitRepo = publicPaykitRepo, paymentRequestRepo = mock(), diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index a5327b4308..68dd141f07 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -112,6 +112,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val settingsStore = mock() private val addressReservationRepo = mock() private val lightningRepo = mock() + private val sparkRepo = mock() private val walletRepo = mock() private val publicPaykitRepo = mock() private val paymentRequestRepo = mock() @@ -196,6 +197,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever { paymentRequestRepo.refreshEligibleTarget(any()) } .thenReturn(Result.success(PaykitPaymentRequestTargetCheck(null, isComplete = true))) whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + whenever { sparkRepo.receivedLightningPaymentHashes() }.thenReturn(emptySet()) + whenever { sparkRepo.attemptedLightningPaymentHashes() }.thenReturn(emptySet()) whenever(coreService.getAddressUsage(any())).doSuspendableAnswer { it.getArgument>(0).associateWith { address -> Result.success(coreService.isAddressUsed(address)) @@ -3191,6 +3194,29 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `private Lightning invoice paid through Spark is replaced`() = test { + preparePrivateLightningContact() + val refreshedInvoice = "lnbcrt1refreshed" + whenever(lightningRepo.createInvoice(null, "", PRIVATE_BOLT11_EXPIRY_SECONDS)) + .thenReturn(Result.success(refreshedInvoice)) + whenever(coreService.decode(refreshedInvoice)) + .thenReturn(Scanner.Lightning(lightningInvoice(refreshedInvoice, byteArrayOf(8, 8, 8)))) + whenever(sparkRepo.receivedLightningPaymentHashes()).thenReturn(setOf("090909")) + clearInvocations(paykitSdkService) + + sut.refreshKnownSavedContactEndpoints("foreground").getOrThrow() + runCurrent() + + val publication = argumentCaptor>() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(publication.capture(), eq(false)) + assertEquals( + PublicPaykitRepo.serializePayload(refreshedInvoice), + publication.firstValue.single().reservations.single().payload, + ) + sut.closeAndClear() + } + @Test fun `forced private Lightning refresh includes contacts loaded later`() = test { preparePrivateLightningContact() @@ -4446,6 +4472,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsStore = settingsStore, addressReservationRepo = addressReservationRepo, lightningRepo = lightningRepo, + sparkRepo = sparkRepo, walletRepo = walletRepo, publicPaykitRepo = publicPaykitRepo, paymentRequestRepo = paymentRequestRepo, diff --git a/app/src/test/java/to/bitkit/repositories/SparkClaimOfferTest.kt b/app/src/test/java/to/bitkit/repositories/SparkClaimOfferTest.kt new file mode 100644 index 0000000000..8c9150b8bb --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkClaimOfferTest.kt @@ -0,0 +1,95 @@ +package to.bitkit.repositories + +import breez_sdk_spark.ClaimDeferredReason +import breez_sdk_spark.ClaimDepositOutcome +import breez_sdk_spark.ClaimDepositQuote +import breez_sdk_spark.FetchClaimDepositQuoteResponse +import org.junit.Test +import kotlin.test.assertEquals + +class SparkClaimOfferTest { + + @Test + fun `offers only the standard claim when no early claim is quoted`() { + val offer = SparkClaimOffer.from(TXID, 1u, quote(instant = null)) + + assertEquals(SparkClaimOffer.Standard(TXID, 1u, feeSats = 907uL, confirmations = 3u), offer) + } + + @Test + fun `leaves an early claim within the cap to the sdk`() { + val offer = SparkClaimOffer.from(TXID, 1u, quote(instant = early(feeSats = 900uL, maxFeeSats = 1_000uL))) + + assertEquals(SparkClaimOffer.Automatic(TXID, 1u, feeSats = 900uL, confirmations = 0u), offer) + } + + @Test + fun `offers a choice when the early claim costs more than the cap`() { + val offer = SparkClaimOffer.from( + TXID, + 1u, + quote(instant = early(feeSats = 1_350uL, maxFeeSats = 1_000uL, confirmations = 1u)), + ) + + assertEquals( + SparkClaimOffer.Choice( + txid = TXID, + vout = 1u, + earlyFeeSats = 1_350uL, + earlyConfirmations = 1u, + standardFeeSats = 907uL, + standardConfirmations = 3u, + ), + offer, + ) + } + + @Test + fun `offers a choice when automatic claiming is off`() { + val offer = SparkClaimOffer.from(TXID, 1u, quote(instant = early(feeSats = 900uL, maxFeeSats = null))) + + assertEquals(SparkClaimOffer.Choice(TXID, 1u, 900uL, 0u, 907uL, 3u), offer) + } + + @Test + fun `maps each claim outcome`() { + assertEquals(SparkEarlyClaim.Claimed, SparkEarlyClaim.from(ClaimDepositOutcome.Submitted)) + assertEquals( + SparkEarlyClaim.Waiting, + SparkEarlyClaim.from(ClaimDepositOutcome.Deferred(ClaimDeferredReason.NoEarlyClaimAvailable)), + ) + assertEquals( + SparkEarlyClaim.Repriced(1_500uL), + SparkEarlyClaim.from(ClaimDepositOutcome.Deferred(ClaimDeferredReason.MaxFeeExceeded(1_500uL, 1_350uL))), + ) + assertEquals( + SparkEarlyClaim.Declined, + SparkEarlyClaim.from(ClaimDepositOutcome.Deferred(ClaimDeferredReason.ProviderDeclined("busy"))), + ) + } + + private fun quote(instant: ClaimDepositQuote?) = FetchClaimDepositQuoteResponse( + amountSats = 15_000uL, + confirmations = 0u, + instant = instant, + mature = ClaimDepositQuote( + confirmationsRequired = 3u, + creditAmountSats = 14_093uL, + feeSats = 907uL, + maxFeeSats = 1_000uL, + isEstimate = false, + ), + ) + + private fun early(feeSats: ULong, maxFeeSats: ULong?, confirmations: UInt = 0u) = ClaimDepositQuote( + confirmationsRequired = confirmations, + creditAmountSats = 15_000uL - feeSats, + feeSats = feeSats, + maxFeeSats = maxFeeSats, + isEstimate = false, + ) + + private companion object { + const val TXID = "txid" + } +} diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt new file mode 100644 index 0000000000..dc56da0703 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -0,0 +1,572 @@ +package to.bitkit.repositories + +import breez_sdk_spark.BitcoinAddressDetails +import breez_sdk_spark.Bolt11InvoiceDetails +import breez_sdk_spark.ClaimDeferredReason +import breez_sdk_spark.ClaimDepositOutcome +import breez_sdk_spark.ClaimDepositQuote +import breez_sdk_spark.DepositInfo +import breez_sdk_spark.FeePolicy +import breez_sdk_spark.FetchClaimDepositQuoteResponse +import breez_sdk_spark.GetInfoResponse +import breez_sdk_spark.ImportUnilateralExitStateResponse +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentDetails +import breez_sdk_spark.PaymentMethod +import breez_sdk_spark.PaymentStatus +import breez_sdk_spark.PaymentType +import breez_sdk_spark.PrepareSendPaymentResponse +import breez_sdk_spark.SdkEvent +import breez_sdk_spark.SdkException +import breez_sdk_spark.SendOnchainFeeQuote +import breez_sdk_spark.SendOnchainSpeedFeeQuote +import breez_sdk_spark.SendPaymentMethod +import breez_sdk_spark.SparkHtlcDetails +import breez_sdk_spark.SparkHtlcStatus +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.OnchainActivity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +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.CacheStore +import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.ext.scopedActivityId +import to.bitkit.models.SparkExitBackupV1 +import to.bitkit.models.WalletScope +import to.bitkit.services.SparkError +import to.bitkit.services.SparkService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.ServiceError +import java.io.File +import java.math.BigInteger +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant +import com.synonym.bitkitcore.PaymentType as ActivityPaymentType + +@OptIn(ExperimentalCoroutinesApi::class) +class SparkRepoTest : BaseUnitTest() { + private companion object { + const val ADDRESS = "bcrt1qsavings" + } + + private val sparkService = mock() + private val activityRepo = mock() + private val sparkEvents = MutableSharedFlow() + private val cacheData = MutableStateFlow(AppCacheData()) + private val cacheStore = mock { on { data } doReturn cacheData } + private val contactResolver = mock() + private val now = Instant.fromEpochSeconds(1_800_000_000) + private val clock = object : Clock { + override fun now() = this@SparkRepoTest.now + } + + private fun createSut() = SparkRepo( + bgDispatcher = testDispatcher, + sparkService = sparkService, + activityRepo = { activityRepo }, + cacheStore = cacheStore, + contactResolver = { contactResolver }, + clock = clock, + ) + + @get:Rule + val tempFolder = TemporaryFolder() + + @Before + fun setUp() { + Env.initAppStoragePath(tempFolder.root.absolutePath) + whenever { sparkService.getInfo() }.thenReturn(GetInfoResponse("pubkey", 0uL, emptyMap(), 0uL)) + whenever { sparkService.listUnclaimedDeposits() }.thenReturn(emptyList()) + whenever { sparkService.listPayments() }.thenReturn(emptyList()) + whenever(sparkService.events).thenReturn(sparkEvents) + whenever { activityRepo.getActivity(any(), any()) }.thenReturn(Result.success(null)) + whenever { activityRepo.upsertActivity(any()) }.thenReturn(Result.success(Unit)) + whenever(sparkService.isSupported).thenReturn(true) + whenever(sparkService.networkName).thenReturn("regtest") + } + + @Test + fun `imports a restored exit backup once Spark connects`() = test { + whenever { sparkService.importExitState(any()) }.thenReturn(ImportUnilateralExitStateResponse(2u, 0u, 0u, 0u)) + val sut = createSut() + + sut.restoreExitBackup(SparkExitBackupV1(createdAt = 1, network = "regtest", exitState = "state"), 0) + .getOrThrow() + verify(sparkService, never()).importExitState(any()) + + whenever(sparkService.isConnected).thenReturn(true) + sut.start(0).getOrThrow() + + verify(sparkService).importExitState("state") + assertFalse(File(Env.sparkExitRestorePath(0)).exists()) + } + + @Test + fun `records a send's fee once a sync reports it`() = test { + whenever(sparkService.isConnected).thenReturn(true) + val sut = createSut() + sut.start(0).getOrThrow() + + sparkEvents.emit(SdkEvent.PaymentSucceeded(sentPayment(fees = 0))) + whenever { sparkService.listPayments() }.thenReturn(listOf(sentPayment(fees = 2))) + sparkEvents.emit(SdkEvent.Synced) + + val activities = argumentCaptor() + verify(activityRepo, times(2)).upsertActivity(activities.capture()) + assertEquals(listOf(0uL, 2uL), activities.allValues.map { (it as Activity.Lightning).v1.fee }) + } + + @Test + fun `marks a withdrawal's arrival as a transfer by the txid its send returned`() = test { + val arrival = OnchainActivity.create( + id = "arrival", + txType = ActivityPaymentType.RECEIVED, + txId = "withdrawal", + value = 4_917uL, + fee = 0uL, + address = "bc1q", + timestamp = 1uL, + ) + whenever { activityRepo.getOnchainActivityByTxId(eq("withdrawal"), any()) }.thenReturn(arrival) + whenever { activityRepo.updateActivity(any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever(sparkService.isConnected).thenReturn(true) + whenever { sparkService.prepareSend(any(), anyOrNull(), anyOrNull()) } + .thenReturn(withdrawal(expiresAt = now.epochSeconds.toULong() + 600uL)) + val sent = sentPayment(fees = 1_470).copy( + method = PaymentMethod.WITHDRAW, + details = PaymentDetails.Withdraw("withdrawal"), + ) + whenever { sparkService.send(any()) }.thenReturn(sent) + val sut = createSut() + + assertFalse(sut.markWithdrawalAsTransfer("withdrawal")) + sut.withdrawAll(ADDRESS).getOrThrow() + + assertTrue(sut.markWithdrawalAsTransfer("withdrawal")) + assertFalse(sut.markWithdrawalAsTransfer("other")) + val updated = argumentCaptor() + verify(activityRepo).updateActivity(eq("arrival"), updated.capture(), any()) + assertTrue((updated.firstValue as Activity.Onchain).v1.isTransfer) + } + + @Test + fun `lists a withdrawal with its fee and marks its arrival in Savings as a transfer`() = test { + val arrival = OnchainActivity.create( + id = "arrival", + txType = ActivityPaymentType.RECEIVED, + txId = "withdrawal", + value = 4_917uL, + fee = 0uL, + address = "bc1q", + timestamp = 1uL, + ) + whenever { activityRepo.getOnchainActivityByTxId(eq("withdrawal"), any()) }.thenReturn(arrival) + whenever { activityRepo.updateActivity(any(), any(), any()) }.thenReturn(Result.success(Unit)) + val withdrawal = sentPayment(fees = 1_470).copy( + method = PaymentMethod.WITHDRAW, + details = PaymentDetails.Withdraw("withdrawal"), + ) + whenever { sparkService.listPayments() }.thenReturn(listOf(withdrawal)) + whenever(sparkService.isConnected).thenReturn(true) + + val sut = createSut() + sut.start(0).getOrThrow() + + val updated = argumentCaptor() + verify(activityRepo).updateActivity(eq("arrival"), updated.capture(), any()) + assertTrue((updated.firstValue as Activity.Onchain).v1.isTransfer) + assertEquals(listOf(SparkWithdrawal("withdrawal", 128uL, 1_470uL)), sut.state.value.withdrawals) + } + + @Test + fun `skips an exit backup from another network`() = test { + val sut = createSut() + + sut.restoreExitBackup(SparkExitBackupV1(createdAt = 1, network = "mainnet", exitState = "state"), 0) + .getOrThrow() + + assertFalse(File(Env.sparkExitRestorePath(0)).exists()) + } + + @Test + fun `lists a broadcast deposit as pending until Spark lists it`() = test { + val sut = createSut() + + sut.onDepositBroadcast("txid", 2_500uL) + + val deposit = sut.state.value.deposits.single() + assertEquals("txid", deposit.txid) + assertEquals(false, deposit.isClaimed) + assertEquals(2_500uL, sut.state.value.pendingDepositSats) + } + + @Test + fun `instant claims early at once when the offer is within the ceiling`() = test { + listDeposit() + whenever { sparkService.fetchClaimQuote("txid", 0u) }.thenReturn(quote(earlyFee = 300uL)) + whenever { sparkService.claimDeposit("txid", 0u, 300uL) }.thenReturn(ClaimDepositOutcome.Submitted) + whenever { sparkService.listPayments() }.thenReturn(emptyList(), listOf(depositPayment())) + val sut = createSut() + + sut.followDepositClaim("txid", SparkClaimSpeed.Instant, ceilingSats = 500uL) + advanceUntilIdle() + + verify(sparkService).claimDeposit("txid", 0u, 300uL) + assertEquals(SparkDepositClaim.Claimed, sut.depositClaims.value["txid"]) + } + + @Test + fun `instant asks the user when the early fee is over the ceiling`() = test { + listDeposit() + whenever { sparkService.fetchClaimQuote("txid", 0u) }.thenReturn(quote(earlyFee = 900uL)) + val sut = createSut() + + sut.followDepositClaim("txid", SparkClaimSpeed.Instant, ceilingSats = 500uL) + advanceUntilIdle() + + val claim = assertIs(sut.depositClaims.value["txid"]) + assertEquals(900uL, claim.offer.earlyFeeSats) + verify(sparkService, never()).claimDeposit(any(), any(), any()) + } + + @Test + fun `paying a repriced claim claims at the new fee`() = test { + listDeposit() + whenever { sparkService.fetchClaimQuote("txid", 0u) }.thenReturn(quote(earlyFee = 900uL)) + whenever { sparkService.claimDeposit("txid", 0u, 900uL) }.thenReturn(ClaimDepositOutcome.Submitted) + val sut = createSut() + sut.followDepositClaim("txid", SparkClaimSpeed.Instant, ceilingSats = 500uL) + advanceUntilIdle() + + sut.payRepricedClaim("txid") + advanceUntilIdle() + + verify(sparkService).claimDeposit("txid", 0u, 900uL) + assertEquals(SparkDepositClaim.Adding, sut.depositClaims.value["txid"]) + } + + @Test + fun `instant shows the new price when the claim comes back repriced`() = test { + listDeposit() + whenever { sparkService.fetchClaimQuote("txid", 0u) }.thenReturn(quote(earlyFee = 300uL)) + whenever { sparkService.claimDeposit("txid", 0u, 300uL) }.thenReturn( + ClaimDepositOutcome.Deferred( + ClaimDeferredReason.MaxFeeExceeded(requiredFeeSats = 450uL, maxFeeSats = 300uL) + ) + ) + val sut = createSut() + + sut.followDepositClaim("txid", SparkClaimSpeed.Instant, ceilingSats = 500uL) + advanceUntilIdle() + + val claim = assertIs(sut.depositClaims.value["txid"]) + assertEquals(450uL, claim.offer.earlyFeeSats) + } + + @Test + fun `instant is unavailable when Spark offers only the standard claim`() = test { + listDeposit() + whenever { sparkService.fetchClaimQuote("txid", 0u) }.thenReturn(quote(earlyFee = null)) + val sut = createSut() + + sut.followDepositClaim("txid", SparkClaimSpeed.Instant, ceilingSats = 500uL) + advanceUntilIdle() + + assertEquals(SparkDepositClaim.Unavailable, sut.depositClaims.value["txid"]) + verify(sparkService, never()).claimDeposit(any(), any(), any()) + } + + @Test + fun `instant is unavailable when the provider declines the early claim`() = test { + listDeposit() + whenever { sparkService.fetchClaimQuote("txid", 0u) }.thenReturn(quote(earlyFee = 300uL)) + whenever { sparkService.claimDeposit("txid", 0u, 300uL) }.thenReturn( + ClaimDepositOutcome.Deferred(ClaimDeferredReason.ProviderDeclined("no")) + ) + val sut = createSut() + + sut.followDepositClaim("txid", SparkClaimSpeed.Instant, ceilingSats = 500uL) + advanceUntilIdle() + + assertEquals(SparkDepositClaim.Unavailable, sut.depositClaims.value["txid"]) + } + + @Test + fun `later leaves the deposit to the claim at maturity`() = test { + val sut = createSut() + + sut.followDepositClaim("txid", SparkClaimSpeed.Later, ceilingSats = 500uL) + advanceUntilIdle() + + assertEquals(SparkDepositClaim.Later, sut.depositClaims.value["txid"]) + verify(sparkService, never()).listUnclaimedDeposits() + verify(sparkService, never()).claimDeposit(any(), any(), any()) + } + + @Test + fun `instant says Spark has not seen the deposit when it is never listed`() = test { + val sut = createSut() + + sut.followDepositClaim("txid", SparkClaimSpeed.Instant, ceilingSats = 500uL) + advanceUntilIdle() + + assertEquals(SparkDepositClaim.NotSeen, sut.depositClaims.value["txid"]) + } + + @Test + fun `claim step follows the offer and the ceiling`() { + val choice = SparkClaimOffer.Choice("txid", 0u, earlyFeeSats = 300uL, 0u, 198uL, 3u) + + assertEquals(SparkClaimStep.KeepLooking, SparkClaimStep.of(null, 500uL)) + assertEquals(SparkClaimStep.ClaimEarly(choice), SparkClaimStep.of(choice, 300uL)) + assertEquals(SparkClaimStep.Ask(choice), SparkClaimStep.of(choice, 299uL)) + assertEquals( + SparkClaimStep.Unavailable, + SparkClaimStep.of(SparkClaimOffer.Standard("txid", 0u, 198uL, 3u), 500uL) + ) + assertEquals(SparkClaimStep.Adding, SparkClaimStep.of(SparkClaimOffer.Automatic("txid", 0u, 250uL, 0u), 500uL)) + } + + @Test + fun `a receive recorded without its event gets its paykit contact`() = test { + whenever { contactResolver.contactPublicKeyForPrivateInvoicePaymentHash("hash") }.thenReturn("pubky-contact") + whenever { sparkService.listPayments() }.thenReturn(listOf(receivedPayment())) + whenever(sparkService.isConnected).thenReturn(true) + + createSut().start(0).getOrThrow() + + val activity = argumentCaptor() + verify(activityRepo).upsertActivity(activity.capture()) + assertEquals("pubky-contact", (activity.firstValue as Activity.Lightning).v1.contact) + } + + @Test + fun `a receive whose contact the user detached stays without one`() = test { + cacheData.value = AppCacheData(detachedActivityContacts = setOf(scopedActivityId(WalletScope.default, "hash"))) + whenever { contactResolver.contactPublicKeyForPrivateInvoicePaymentHash("hash") }.thenReturn("pubky-contact") + whenever { sparkService.listPayments() }.thenReturn(listOf(receivedPayment())) + whenever(sparkService.isConnected).thenReturn(true) + + createSut().start(0).getOrThrow() + + val activity = argumentCaptor() + verify(activityRepo).upsertActivity(activity.capture()) + assertNull((activity.firstValue as Activity.Lightning).v1.contact) + verify(contactResolver, never()).contactPublicKeyForPrivateInvoicePaymentHash(any()) + } + + @Test + fun `lightning send lookup reads spark's payments and waits until spark has synced them`() = test { + val sut = createSut() + assertEquals(SparkLightningSend.Unavailable, sut.lightningSend("hash")) + + whenever(sparkService.isConnected).thenReturn(true) + val failed = sentPayment(fees = 0).copy(id = "failed", status = PaymentStatus.FAILED) + sut.start(0).getOrThrow() + whenever { sparkService.listPayments() }.thenReturn(listOf(failed, sentPayment(fees = 1))) + // A restored wallet lists its payments only after its first sync. + assertEquals(SparkLightningSend.Unavailable, sut.lightningSend("hash")) + + sparkEvents.emit(SdkEvent.Synced) + assertEquals(SparkLightningSend.Found(PaymentStatus.COMPLETED, "preimage"), sut.lightningSend("HASH")) + assertEquals(SparkLightningSend.NotFound, sut.lightningSend("other")) + + whenever { sparkService.listPayments() }.thenAnswer { throw SdkException.Generic("offline") } + assertEquals(SparkLightningSend.Unavailable, sut.lightningSend("hash")) + } + + @Test + fun `a send spark refuses before it leaves is a typed refusal`() = test { + val sut = createSut() + assertIs(sut.payInvoice("lnbc1", 100uL).exceptionOrNull()) + + whenever(sparkService.isConnected).thenReturn(true) + val noRoute = SdkException.Generic("no route") + whenever { sparkService.prepareSend(any(), anyOrNull(), anyOrNull()) }.thenAnswer { throw noRoute } + assertIs(sut.payInvoice("lnbc1", 100uL).exceptionOrNull()) + + verify(sparkService, never()).send(any()) + } + + @Test + fun `a spark send over the fee limit is refused before it leaves`() = test { + whenever(sparkService.isConnected).thenReturn(true) + val prepared = prepared(feeSats = 2uL) + whenever { sparkService.prepareSend(any(), anyOrNull(), anyOrNull()) }.thenReturn(prepared) + val sut = createSut() + + assertIs(sut.payInvoice("lnbc1", 100uL, maxTotalFeeMsat = 1_999uL).exceptionOrNull()) + verify(sparkService, never()).send(any()) + + whenever { sparkService.send(any()) }.thenReturn(sentPayment(fees = 2)) + assertEquals("hash", sut.payInvoice("lnbc1", 100uL, maxTotalFeeMsat = 2_000uL).getOrThrow()) + } + + @Test + fun `a spark withdrawal pays the quote its fee estimate priced while it holds`() = test { + whenever(sparkService.isConnected).thenReturn(true) + val held = withdrawal(expiresAt = now.epochSeconds.toULong() + 600uL) + whenever { sparkService.prepareSend(any(), anyOrNull(), anyOrNull()) }.thenReturn(held) + whenever { sparkService.send(any()) }.thenReturn(sentPayment(fees = 0)) + val sut = createSut() + + assertEquals(500uL, sut.estimateWithdrawAllFeeSats(ADDRESS).getOrThrow()) + sut.withdrawAll(ADDRESS).getOrThrow() + + verify(sparkService, times(1)).prepareSend(any(), anyOrNull(), anyOrNull()) + verify(sparkService).send(held) + } + + @Test + fun `a spark withdrawal asks again once its quote expired or the address changed`() = test { + whenever(sparkService.isConnected).thenReturn(true) + val expiring = withdrawal(expiresAt = now.epochSeconds.toULong() + 10uL) + whenever { sparkService.prepareSend(any(), anyOrNull(), anyOrNull()) }.thenReturn(expiring) + val sut = createSut() + + sut.estimateWithdrawAllFeeSats(ADDRESS).getOrThrow() + sut.estimateWithdrawAllFeeSats(ADDRESS).getOrThrow() + verify(sparkService, times(2)).prepareSend(any(), anyOrNull(), anyOrNull()) + + whenever { sparkService.prepareSend(any(), anyOrNull(), anyOrNull()) } + .thenReturn(withdrawal(expiresAt = now.epochSeconds.toULong() + 600uL)) + sut.estimateWithdrawAllFeeSats(ADDRESS).getOrThrow() + sut.estimateWithdrawAllFeeSats("bcrt1qother").getOrThrow() + verify(sparkService, times(4)).prepareSend(any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `a spark send past its deadline never leaves`() = test { + whenever(sparkService.isConnected).thenReturn(true) + val prepared = prepared(feeSats = 0uL) + whenever { sparkService.prepareSend(any(), anyOrNull(), anyOrNull()) }.thenReturn(prepared) + val sut = createSut() + + val result = sut.payInvoice("lnbc1", 100uL, paymentDeadlineAt = now - 1.minutes) + + assertIs(result.exceptionOrNull()) + verify(sparkService, never()).send(any()) + } + + @Test + fun `a failure inside the spark send stays uncertain`() = test { + whenever(sparkService.isConnected).thenReturn(true) + val prepared = prepared(feeSats = 0uL) + whenever { sparkService.prepareSend(any(), anyOrNull(), anyOrNull()) }.thenReturn(prepared) + whenever { sparkService.send(any()) }.thenAnswer { throw SdkException.Generic("timeout") } + + val error = createSut().payInvoice("lnbc1", 100uL).exceptionOrNull() + + assertIs(error) + } + + @Test + fun `can send leaves room for spark's service fee`() = test { + whenever(sparkService.isConnected).thenReturn(true) + whenever { sparkService.getInfo() }.thenReturn(GetInfoResponse("pubkey", 100_000uL, emptyMap(), 0uL)) + val sut = createSut() + sut.start(0).getOrThrow() + + assertEquals(99_750uL, sut.state.value.sendableSats) + assertTrue(sut.canSend(99_750uL)) + assertFalse(sut.canSend(100_000uL)) + } + + @Test + fun `sendable amount fits the fee beside it`() { + for (balance in listOf(0uL, 1uL, 400uL, 401uL, 9_572uL, 100_000uL, 21_000_000uL)) { + val sendable = SparkFees.sendableSats(balance) + assertTrue(sendable + SparkFees.serviceFeeSats(sendable) <= balance, "$balance") + assertTrue(sendable + 1uL + SparkFees.serviceFeeSats(sendable + 1uL) > balance, "$balance leaves room") + } + } + + private fun prepared(feeSats: ULong): PrepareSendPaymentResponse { + val details = mock { on { paymentHash } doReturn "hash" } + return PrepareSendPaymentResponse( + paymentMethod = SendPaymentMethod.Bolt11Invoice(details, null, feeSats), + amount = BigInteger.valueOf(100), + tokenIdentifier = null, + conversionEstimate = null, + feePolicy = FeePolicy.FEES_EXCLUDED, + ) + } + + private fun withdrawal(expiresAt: ULong): PrepareSendPaymentResponse { + val speed = SendOnchainSpeedFeeQuote(userFeeSat = 300uL, l1BroadcastFeeSat = 200uL) + val quote = SendOnchainFeeQuote("quote", expiresAt, speed, speed, speed, isEstimate = false) + return PrepareSendPaymentResponse( + paymentMethod = SendPaymentMethod.BitcoinAddress(mock(), quote), + amount = BigInteger.valueOf(100), + tokenIdentifier = null, + conversionEstimate = null, + feePolicy = FeePolicy.FEES_INCLUDED, + ) + } + + private fun receivedPayment() = sentPayment(fees = 0).copy(id = "receive", paymentType = PaymentType.RECEIVE) + + private fun listDeposit() { + whenever { sparkService.listUnclaimedDeposits() }.thenReturn( + listOf(DepositInfo("txid", 0u, 50_000uL, false, null, null, null, null, null, null)) + ) + } + + private fun quote(earlyFee: ULong?) = FetchClaimDepositQuoteResponse( + amountSats = 50_000uL, + confirmations = 0u, + instant = earlyFee?.let { ClaimDepositQuote(0u, 50_000uL - it, it, 200uL, false) }, + mature = ClaimDepositQuote(3u, 49_802uL, 198uL, null, true), + ) + + private fun depositPayment() = sentPayment(fees = 300).copy( + id = "deposit", + paymentType = PaymentType.RECEIVE, + method = PaymentMethod.DEPOSIT, + details = PaymentDetails.Deposit(txId = "txid", vout = 0u), + ) + + private fun sentPayment(fees: Int) = Payment( + id = "send", + paymentType = PaymentType.SEND, + status = PaymentStatus.COMPLETED, + amount = BigInteger.valueOf(128), + fees = BigInteger.valueOf(fees.toLong()), + timestamp = 1uL, + method = PaymentMethod.LIGHTNING, + details = PaymentDetails.Lightning( + description = null, + invoice = "lnbc1280n1send", + destinationPubkey = "pubkey", + htlcDetails = SparkHtlcDetails("hash", "preimage", 1uL, SparkHtlcStatus.PREIMAGE_SHARED), + lnurlPayInfo = null, + lnurlWithdrawInfo = null, + lnurlReceiveMetadata = null, + conversionInfo = null, + ), + conversionDetails = null, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c57889118f..68c7bed28c 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -57,6 +57,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest import to.bitkit.test.connectionRecoveryFixture +import to.bitkit.test.idleSparkRepo import to.bitkit.ui.screens.wallets.receive.ReceiveTab import to.bitkit.ui.screens.wallets.receive.getInvoiceForTab import to.bitkit.usecases.DeriveBalanceStateUseCase @@ -77,6 +78,7 @@ class WalletRepoTest : BaseUnitTest() { private lateinit var sut: WalletRepo private val keychain = mock() + private val sparkRepo = idleSparkRepo() private val coreService = mock() private val settingsStore = mock() private val lightningRepo = mock() @@ -188,6 +190,7 @@ class WalletRepoTest : BaseUnitTest() { activityRepo = activityRepo, hwWalletRepo = hwWalletRepo, connectionRecovery = connectionRecovery, + sparkRepo = sparkRepo, ) private fun lightningInvoice(bolt11: String, isExpired: Boolean = false) = LightningInvoice( diff --git a/app/src/test/java/to/bitkit/services/SparkServiceTest.kt b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt new file mode 100644 index 0000000000..cb005cedc8 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt @@ -0,0 +1,73 @@ +package to.bitkit.services + +import breez_sdk_spark.Config +import breez_sdk_spark.LeafOptimizationConfig +import breez_sdk_spark.Network +import breez_sdk_spark.SendPaymentOptions +import breez_sdk_spark.TokenOptimizationConfig +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class SparkServiceTest { + @Test + fun `lightning sends return before completion so callers see the completion event`() { + val options = assertIs(SparkService.bolt11SendOptions(prefersSpark = true)) + + assertNull(options.completionTimeoutSecs) + assertEquals(true, options.preferSpark) + } + + @Test + fun `claim fee estimate covers the claims seen on mainnet`() { + // Mainnet claims so far took 198 and 297 sats; the cap at 1 sat/vB stays above both. + assertEquals(302uL, SparkService.maxDepositClaimFeeSats(fastestFeeRate = 1uL, amountSats = 2_500uL)) + assertEquals(327uL, SparkService.maxDepositClaimFeeSats(fastestFeeRate = 1uL, amountSats = 15_000uL)) + } + + @Test + fun `later claim fee prices the mature claim at the half-hour rate`() { + // Run 2 quoted the mature claim at 198 sats at 2 sat/vB. + assertEquals(198uL, SparkService.matureDepositClaimFeeSats(halfHourFeeRate = 2uL)) + } + + /** A config as `defaultConfig` returns it, with Breez's data sync and Lightning address servers set. */ + private fun defaults(network: Network) = Config( + apiKey = null, + network = network, + syncIntervalSecs = 60u, + maxDepositClaimFee = null, + lnurlDomain = "breez.tips", + preferSparkOverLightning = false, + exitChainAutoFetchEnabled = true, + watchtowerFeeLadderEnabled = null, + externalInputParsers = null, + useDefaultExternalInputParsers = true, + realTimeSyncServerUrl = "https://datasync.breez.technology", + privateEnabledDefault = true, + leafOptimizationConfig = LeafOptimizationConfig(autoEnabled = true, multiplicity = 1u), + tokenOptimizationConfig = TokenOptimizationConfig( + autoEnabled = true, + targetOutputCount = 1u, + minOutputsThreshold = 1u, + ), + stableBalanceConfig = null, + maxConcurrentClaims = 4u, + sparkConfig = null, + backgroundTasksEnabled = true, + proxy = null, + crossChainConfig = null, + ) + + @Test + fun `config turns off breez data sync and the lightning address server on every network`() { + Network.entries.forEach { network -> + val config = SparkService.configure(defaults(network), apiKey = "key") + + assertNull(config.realTimeSyncServerUrl, "$network") + assertNull(config.lnurlDomain, "$network") + assertEquals("key", config.apiKey) + } + } +} diff --git a/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt b/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt new file mode 100644 index 0000000000..e77e84ceb7 --- /dev/null +++ b/app/src/test/java/to/bitkit/test/IdleSparkRepo.kt @@ -0,0 +1,14 @@ +package to.bitkit.test + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState + +/** A Spark wallet that is not connected, so every flow takes its Blocktank and channel path. */ +fun idleSparkRepo(): SparkRepo = mock { + on { state } doReturn MutableStateFlow(SparkState()) + on { nodeEvents } doReturn MutableSharedFlow() +} diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index abab203eb9..5adcaec1dd 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -270,6 +270,33 @@ class ContentViewTest { assertTrue(navController.currentDestination?.hasRoute() == true) } + @Test + fun `spending start route uses intro until seen`() { + assertEquals(Routes.SpendingIntro, transferSpendingStartRoute(hasSeenSpendingIntro = false)) + assertEquals(Routes.SparkFundingNav, transferSpendingStartRoute(hasSeenSpendingIntro = true)) + } + + @Test + fun `hardware spending start route keeps the wallet id after intro`() { + val walletId = "trezor-1" + + assertEquals(Routes.SpendingIntroHw(walletId), transferSpendingStartRoute(false, walletId)) + assertEquals(Routes.SpendingAmountHw(walletId), transferSpendingStartRoute(true, walletId)) + } + + @Test + fun `setting up removes spending confirmation from the back stack`() { + val navController = transferNavController() + navController.navigateTo(Routes.SparkFundingNav) + navController.navigateTo(Routes.SpendingConfirm) + + navController.navigateToSettingUp() + assertTrue(navController.currentDestination?.hasRoute() == true) + + navController.popBackStack() + assertTrue(navController.currentDestination?.hasRoute() == true) + } + private fun transferNavController(): NavHostController = NavHostController(ApplicationProvider.getApplicationContext()).apply { navigatorProvider.addNavigator(ComposeNavigator()) @@ -282,6 +309,11 @@ class ContentViewTest { composable {} composable {} } + navigation(startDestination = Routes.SpendingAmount) { + composable {} + composable {} + composable {} + } } } } diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index f3b3a7403d..158bb3669c 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -57,6 +57,7 @@ import to.bitkit.services.MigrationService import to.bitkit.services.PendingChannelMigration import to.bitkit.test.BaseUnitTest import to.bitkit.test.connectionRecoveryFixture +import to.bitkit.test.idleSparkRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.viewmodels.RestoreState @@ -86,6 +87,7 @@ class WalletViewModelTest : BaseUnitTest() { private val migrationService = mock() private val connectivityRepo = mock() private val boltzService = mock() + private val sparkRepo = idleSparkRepo() private val lightningState = MutableStateFlow(LightningState()) private val walletState = MutableStateFlow(WalletState()) @@ -126,6 +128,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + sparkRepo = sparkRepo, ) } @@ -1176,6 +1179,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + sparkRepo = sparkRepo, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -1243,6 +1247,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + sparkRepo = sparkRepo, ) testSut.start() @@ -1299,6 +1304,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + sparkRepo = sparkRepo, ) // Trigger restore to put state in non-idle @@ -1339,6 +1345,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + sparkRepo = sparkRepo, ) testSut.restoreWallet("mnemonic", null) @@ -1430,6 +1437,7 @@ class WalletViewModelTest : BaseUnitTest() { connectivityRepo = connectivityRepo, boltzService = boltzService, connectionRecovery = connectionRecovery, + sparkRepo = sparkRepo, ) advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/ui/screens/transfer/SparkFundingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/transfer/SparkFundingViewModelTest.kt new file mode 100644 index 0000000000..bf92b5c5d0 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/transfer/SparkFundingViewModelTest.kt @@ -0,0 +1,240 @@ +package to.bitkit.ui.screens.transfer + +import android.content.Context +import com.synonym.bitkitcore.FeeRates +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.BalanceDetails +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.models.BalanceState +import to.bitkit.models.Toast +import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.SparkClaimSpeed +import to.bitkit.repositories.SparkDepositClaim +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.transfer.SparkFundingContract.SideEffect +import to.bitkit.ui.shared.toast.ToastEventBus +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SparkFundingViewModelTest : BaseUnitTest() { + private val context = mock() + private val walletRepo = mock() + private val lightningRepo = mock() + private val sparkRepo = mock() + private val currencyRepo = mock() + private val hwWalletRepo = mock() + private val feeRates = FeeRates(fast = 5u, mid = 3u, slow = 1u) + + private fun createSut() = + SparkFundingViewModel(context, walletRepo, lightningRepo, sparkRepo, currencyRepo, hwWalletRepo) + + @Before + fun setUp() { + whenever(context.getString(any())).thenReturn("text {amount}") + whenever(context.getString(R.string.lightning__spending_amount__error_min__title)).thenReturn("Too small") + whenever(context.getString(R.string.lightning__spending_confirm__fees_changed_title)).thenReturn("Changed") + whenever(context.getString(R.string.lightning__spending_amount__error_balance__title)).thenReturn("Short") + whenever(walletRepo.balanceState).thenReturn(MutableStateFlow(BalanceState(maxSendOnchainSats = 100_000u))) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(currencyRepo.currencyState).thenReturn(MutableStateFlow(CurrencyState())) + whenever(sparkRepo.depositClaims).thenReturn(MutableStateFlow(emptyMap())) + whenever { sparkRepo.depositAddress() }.thenReturn(Result.success("bcrt1qspark")) + whenever { sparkRepo.estimateDepositClaimFeeSats(any()) }.thenReturn(Result.success(1_350uL)) + whenever { sparkRepo.estimateMatureClaimFeeSats() }.thenReturn(Result.success(198uL)) + whenever { lightningRepo.getFeeRates() }.thenReturn(Result.success(feeRates)) + whenever { lightningRepo.getBalancesAsync() }.thenReturn(Result.success(balances(100_000uL))) + whenever { lightningRepo.estimateSendAllFee(any(), any(), any()) }.thenReturn(Result.success(500uL)) + whenever { lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), any()) } + .thenReturn(Result.success(500uL)) + } + + @Test + fun `max is the savings balance less the network fee and 25 percent is a quarter of it`() = test { + val sut = createSut() + advanceUntilIdle() + + assertEquals(99_500L, sut.uiState.value.maxSats) + assertEquals(24_875L, sut.quarterSats()) + } + + @Test + fun `max is zero without savings`() = test { + whenever { lightningRepo.getBalancesAsync() }.thenReturn(Result.success(balances(0uL))) + val sut = createSut() + advanceUntilIdle() + + assertEquals(0L, sut.uiState.value.maxSats) + } + + @Test + fun `continue refuses an amount that does not cover the instant fee`() = test { + val sut = createSut() + advanceUntilIdle() + sut.onAmountChange(1_000) + + val toasts = collectToasts { sut.onAmountContinue() } + + assertEquals("Too small", toasts.single().title) + assertEquals(null, sut.uiState.value.quote) + } + + @Test + fun `continue prices the transfer and opens confirm`() = test { + val sut = createSut() + advanceUntilIdle() + sut.onAmountChange(50_000) + val effects = mutableListOf() + val job = launch { sut.effects.collect { effects.add(it) } } + + sut.onAmountContinue() + advanceUntilIdle() + job.cancel() + + assertEquals(listOf(SideEffect.AmountReady), effects) + val quote = requireNotNull(sut.uiState.value.quote) + assertEquals(50_500L, quote.totalSats) + assertEquals(48_650L, quote.toSpendingSats(SparkClaimSpeed.Instant)) + assertEquals(49_802L, quote.toSpendingSats(SparkClaimSpeed.Later)) + } + + @Test + fun `swipe asks again when the network fee rose since shown`() = test { + val sut = createSut() + advanceUntilIdle() + sut.onAmountChange(50_000) + sut.onAmountContinue() + advanceUntilIdle() + whenever { lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), any()) } + .thenReturn(Result.success(800uL)) + + val toasts = collectToasts { sut.onConfirm() } + + assertEquals("Changed", toasts.single().title) + assertEquals(50_800L, sut.uiState.value.quote?.totalSats) + assertEquals(false, sut.uiState.value.isSending) + 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 `swipe refuses with real copy when savings no longer cover the transfer`() = test { + val sut = createSut() + advanceUntilIdle() + sut.onAmountChange(50_000) + sut.onAmountContinue() + advanceUntilIdle() + whenever { lightningRepo.getBalancesAsync() }.thenReturn(Result.success(balances(40_000uL))) + + val toasts = collectToasts { sut.onConfirm() } + + assertEquals("Short", toasts.single().title) + assertEquals(false, sut.uiState.value.isSending) + } + + @Test + fun `swipe sends at the quoted fee rates and follows the claim at the chosen speed`() = test { + whenever { + lightningRepo.sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), + any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + }.thenReturn(Result.success(OnchainSendOutcome.Accepted("txid", 50_000uL))) + whenever(sparkRepo.depositClaims).thenReturn(MutableStateFlow(mapOf("txid" to SparkDepositClaim.Later))) + val sut = createSut() + advanceUntilIdle() + sut.onAmountChange(50_000) + sut.onAmountContinue() + advanceUntilIdle() + sut.onSpeedChange(SparkClaimSpeed.Later) + advanceUntilIdle() + + sut.onConfirm() + advanceUntilIdle() + + verify(lightningRepo).sendOnChain( + eq("bcrt1qspark"), eq(50_000uL), eq(TransactionSpeed.Fast), anyOrNull(), eq(feeRates), eq(true), + anyOrNull(), eq(false), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + verify(sparkRepo).onDepositBroadcast("txid", 50_000uL) + verify(sparkRepo).followDepositClaim("txid", SparkClaimSpeed.Later, 1_350uL) + assertEquals("txid", sut.uiState.value.txid) + assertTrue(sut.uiState.value.claim == SparkDepositClaim.Later) + } + + @Test + fun `hardware transfer prices from the device and hands signing a transfer request`() = test { + whenever { hwWalletRepo.maxSpendableFunding("trezor", "bcrt1qspark", 5uL) }.thenReturn(Result.success(80_000uL)) + whenever { hwWalletRepo.estimateFundingMiningFee("trezor", "bcrt1qspark", 50_000uL, 5uL) } + .thenReturn(Result.success(700uL)) + val sut = createSut() + sut.useHardwareWallet("trezor") + advanceUntilIdle() + assertEquals(80_000L, sut.uiState.value.maxSats) + sut.onAmountChange(50_000) + sut.onAmountContinue() + advanceUntilIdle() + val effects = mutableListOf() + val job = launch { sut.effects.collect { effects.add(it) } } + + sut.onConfirm() + advanceUntilIdle() + job.cancel() + + val request = (effects.single() as SideEffect.SignHw).request + assertEquals("trezor", request.walletId) + assertEquals("bcrt1qspark", request.address) + assertEquals(50_000uL, request.amountSats) + assertEquals(5uL, request.satsPerVByte) + assertTrue(request.isTransfer) + assertEquals(700L, sut.uiState.value.quote?.networkFeeSats) + } + + private suspend fun TestScope.collectToasts(action: () -> Unit): List { + val toasts = mutableListOf() + val job = launch { ToastEventBus.events.collect { toasts.add(it) } } + advanceUntilIdle() + action() + advanceUntilIdle() + job.cancel() + return toasts + } + + private fun balances(spendable: ULong) = BalanceDetails( + totalOnchainBalanceSats = spendable, + spendableOnchainBalanceSats = spendable, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ) +} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt index 0fc77baca2..b6a92f02b0 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 @@ -43,6 +43,25 @@ class EditInvoiceVMTest : BaseUnitTest() { verify(walletRepo, times(1)).inboundLiquiditySats() } + @Test + fun `onClickContinue should emit none for spending above inbound when Spark can receive`() = test { + whenever(walletRepo.canReceiveWithSpark()).thenReturn(true) + + sut.editInvoiceEffect.test { + sut.onClickContinue( + source = ReceiveLiquiditySource.SPENDING, + amountSats = 10_000u, + ) + + assertEquals( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + verify(walletRepo, times(0)).inboundLiquiditySats() + } + @Test fun `onClickContinue should emit transfer unavailable for spending above inbound`() = test { sut.editInvoiceEffect.test { diff --git a/app/src/test/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModelTest.kt index c354e5cf0a..d5e0e15fd1 100644 --- a/app/src/test/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModelTest.kt @@ -19,6 +19,8 @@ import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.LogsRepo +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -32,6 +34,7 @@ class LightningConnectionsViewModelTest : BaseUnitTest() { private val blocktankRepo = mock() private val logsRepo = mock() private val activityRepo = mock() + private val sparkRepo = mock() private val lightningState = MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running)) private val lastKnownChannels = MutableStateFlow( @@ -44,6 +47,7 @@ class LightningConnectionsViewModelTest : BaseUnitTest() { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(lightningRepo.lastKnownChannels).thenReturn(lastKnownChannels) whenever(lightningRepo.nodeEvents).thenReturn(emptyFlow()) + whenever(sparkRepo.state).thenReturn(MutableStateFlow(SparkState())) whenever { activityRepo.getClosedChannels(SortDirection.DESC) }.thenReturn(Result.success(emptyList())) @@ -74,5 +78,6 @@ class LightningConnectionsViewModelTest : BaseUnitTest() { blocktankRepo = blocktankRepo, logsRepo = logsRepo, activityRepo = activityRepo, + sparkRepo = sparkRepo, ) } diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index 342da8dd1d..800be7030a 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -23,8 +23,11 @@ import to.bitkit.models.TransferType import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState import to.bitkit.repositories.TransferRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -34,6 +37,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { private val transferRepo: TransferRepo = mock() private val settingsStore: SettingsStore = mock() private val hwWalletRepo: HwWalletRepo = mock() + private val sparkRepo: SparkRepo = idleSparkRepo() private lateinit var sut: DeriveBalanceStateUseCase @@ -60,9 +64,26 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { transferRepo = transferRepo, settingsStore = settingsStore, hwWalletRepo = hwWalletRepo, + sparkRepo = sparkRepo, ) } + @Test + fun `should add Spark balance to spending and pending deposits to transfer`() = test { + newBalanceDetails() + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(sparkRepo.state).thenReturn( + MutableStateFlow(SparkState(isReady = true, balanceSats = 30_000uL, pendingDepositSats = 20_000uL)), + ) + + val balanceState = sut().getOrThrow() + + assertEquals(80_000uL, balanceState.totalLightningSats) + // Spark keeps 0.25% of a send as its fee, so the most it can send leaves room for it. + assertEquals(29_925uL, balanceState.maxSendLightningSats) + assertEquals(20_000uL, balanceState.balanceInTransferToSpending) + } + @Test fun `should subtract estimated fee from channelFundableBalance`() = test { val fundableBalance = 75_000uL diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 89de855cf8..ac1d2b580f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -325,6 +325,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ContactPaymentSettingsRepo(settingsStore, publicPaykitRepo, privatePaykitRepo, pubkyRepo, testDispatcher) } private val isRecoveryMode = MutableStateFlow(false) + private val isSparkReady = MutableStateFlow(false) private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) private val nodeEventUpdates = MutableSharedFlow() @@ -709,6 +710,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(lightningRepo.isRecoveryMode).thenReturn(isRecoveryMode) + whenever(lightningRepo.isSparkReady).thenReturn(isSparkReady) whenever(lightningRepo.nodeEventUpdates).thenReturn(nodeEventUpdates) whenever(lightningRepo.nodeEvents).thenReturn(nodeEventUpdates.map { it.event }) whenever(hwWalletRepo.receivedTxs).thenReturn(hwReceivedTxs) @@ -2627,6 +2629,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals("The payment request is no longer available.", terminalToast.description) } + @Test + fun `manual request whose resolution fails stops loading at once`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.slashtags__error_pay_waiting_msg)).thenReturn("Try again") + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.failure(AppError("Pubky shared state remains locked; retry later")) + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo, times(1)).beginPaymentRequest(request) + assertNull(sut.requestedPaymentRequestId.value) + val toast = argumentCaptor() + verify(toastManager).enqueue(toast.capture()) + assertEquals("Try again", toast.firstValue.description) + } + @Test fun `opened request with an invalid target returns to the request sheet`() = test { sut.setIsAuthenticated(true) @@ -3239,7 +3271,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `failed request opened from the full screen does not replace it with the request sheet`() = test { + fun `failed request opened from the full screen stops loading in place`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") @@ -3262,16 +3294,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) verify(paykitPaymentRequestRepo).markPresented(request) - verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) - verify(paykitPaymentRequestDiagnostics, times(15)).logPresentationRejection( + verify(privatePaykitRepo, times(1)).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( request.counterparty, IncomingPaykitPaymentRequestFailureReason.NoSupportedEndpoint, ) val toastCaptor = argumentCaptor() - verify(toastManager, times(2)).enqueue(toastCaptor.capture()) - val (waitingToast, terminalToast) = toastCaptor.allValues - assertNull(waitingToast.testTag) - assertEquals("PaymentRequestUnavailableToast", terminalToast.testTag) + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestWaitingForDetailsToast", toastCaptor.firstValue.testTag) } @Test @@ -14949,6 +14979,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } + @Test + fun `spark send that settled before the watch started completes without pending`() = test { + val bolt11 = "lnbcrt1sparktospark" + val paymentHash = "spark_hash" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) + whenever(lightningRepo.settledSparkSendEvent(paymentHash)).thenReturn( + Event.PaymentSuccessful( + paymentId = paymentHash, + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 0uL, + ), + ) + setSendState( + SendUiState( + address = bolt11, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceTimeBy(LightningRepo.SEND_LN_TIMEOUT.inWholeMilliseconds + 1_000) + runCurrent() + + verify(pendingPaymentRepo, never()).track(paymentHash) + } + @Test fun `private lightning pending payment consumes private list`() = test { val bolt11 = "lnbcrt1pending" @@ -15215,6 +15275,24 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `spark becoming ready republishes the paykit lightning endpoints`() = test { + enablePublicPaykitSharing() + advanceUntilIdle() + clearInvocations(publicPaykitRepo, privatePaykitRepo) + + isSparkReady.value = true + advanceUntilIdle() + + verify(publicPaykitRepo).requestLightningRefresh(anyOrNull(), anyOrNull()) + verify(privatePaykitRepo).refreshKnownSavedContactEndpoints( + eq("spark ready"), + eq(true), + any(), + anyOrNull(), + ) + } + @Test fun `forced public refresh survives deferred initial restoration without another channel event`() = test { enablePublicPaykitSharing() diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 9c7e581058..d970738dfa 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -5,7 +5,9 @@ import app.cash.turbine.test import com.synonym.bitkitcore.BoltzPairInfo import com.synonym.bitkitcore.BoltzSwapEvent import com.synonym.bitkitcore.ReverseSwapResponse +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceTimeBy @@ -18,16 +20,20 @@ import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentFailureReason import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +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.R +import to.bitkit.ext.createChannelDetails import to.bitkit.models.BalanceState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.SparkRepo +import to.bitkit.repositories.SparkState import to.bitkit.repositories.TransferRepo -import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError @@ -36,6 +42,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse 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 @@ -46,10 +53,11 @@ class TransferViewModelTest : BaseUnitTest() { private val context = mock() private val lightningRepo = mock() - private val walletRepo = mock() private val transferRepo = mock() private val clock = mock() private val boltzService = mock() + private val sparkState = MutableStateFlow(SparkState()) + private val sparkRepo = mock() private val balanceState = MutableStateFlow(BalanceState()) private val boltzEvents = MutableSharedFlow(extraBufferCapacity = 8) @@ -61,28 +69,167 @@ class TransferViewModelTest : BaseUnitTest() { val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) - whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(boltzService.events).thenReturn(boltzEvents) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(boltzService.isSwapSupported).thenReturn(true) whenever { boltzService.isSwapEnabled() }.thenReturn(true) + whenever(sparkRepo.state).thenReturn(sparkState) + whenever { sparkRepo.withdrawAll(any()) }.thenReturn(Result.success(Unit)) + whenever { lightningRepo.newAddress() }.thenReturn(Result.success(CLAIM_ADDRESS)) + whenever { lightningRepo.closeChannel(any(), any(), anyOrNull()) }.thenReturn(Result.success(Unit)) sut = TransferViewModel( context = context, lightningRepo = lightningRepo, - walletRepo = walletRepo, transferRepo = transferRepo, boltzService = boltzService, clock = clock, + sparkRepo = sparkRepo, ) } + @Test + fun `savings selection moves everything by default`() { + val selection = SavingsSelection() + + assertTrue(selection.isAll) + assertEquals(listOf(CHANNEL_A, CHANNEL_B), selection.channels(listOf(CHANNEL_A, CHANNEL_B))) + assertTrue(selection.withdrawsSpark(SPARK_SATS)) + assertFalse(selection.withdrawsSpark(0uL)) + } + + @Test + fun `savings selection moves only the picked channels and spark`() { + val channelsOnly = SavingsSelection(channelIds = persistentSetOf(CHANNEL_B.channelId), includesSpark = false) + val sparkOnly = SavingsSelection(channelIds = persistentSetOf(), includesSpark = true) + + assertFalse(channelsOnly.isAll) + assertEquals(listOf(CHANNEL_B), channelsOnly.channels(listOf(CHANNEL_A, CHANNEL_B))) + assertFalse(channelsOnly.withdrawsSpark(SPARK_SATS)) + assertFalse(sparkOnly.isAll) + assertTrue(sparkOnly.channels(listOf(CHANNEL_A, CHANNEL_B)).isEmpty()) + assertTrue(sparkOnly.withdrawsSpark(SPARK_SATS)) + } + + @Test + fun `transfer to savings with only spark withdraws spark and closes no channel`() = test { + sparkState.value = SparkState(balanceSats = SPARK_SATS) + sut.onTransferToSavingsConfirm(channels = emptyList(), withdrawSpark = true) + + assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) + assertFalse(sut.hasChannelsToClose()) + assertTrue(sut.closeSelectedChannels().isEmpty()) + verify(sparkRepo).withdrawAll(CLAIM_ADDRESS) + verify(lightningRepo, never()).awaitPeerConnected(any()) + verify(lightningRepo, never()).closeChannel(any(), any(), anyOrNull()) + } + + @Test + fun `transfer to savings with only channels leaves spark untouched`() = test { + sparkState.value = SparkState(balanceSats = SPARK_SATS) + sut.onTransferToSavingsConfirm(channels = listOf(CHANNEL_B), withdrawSpark = false) + + assertTrue(sut.closeSelectedChannels().isEmpty()) + verify(sparkRepo, never()).withdrawAll(any()) + verify(lightningRepo).closeChannel(CHANNEL_B, false, null) + verify(lightningRepo, never()).closeChannel(CHANNEL_A, false, null) + } + + @Test + fun `a failed spark withdrawal is reported while the selected channels still close`() = test { + sparkState.value = SparkState(balanceSats = SPARK_SATS) + whenever { sparkRepo.withdrawAll(any()) }.thenReturn(Result.failure(AppError(PAY_ERROR))) + sut.onTransferToSavingsConfirm(channels = listOf(CHANNEL_A), withdrawSpark = true) + + assertTrue(sut.closeSelectedChannels().isEmpty()) + + assertTrue(sut.isSparkWithdrawFailed.value) + verify(lightningRepo).closeChannel(CHANNEL_A, false, null) + + sut.onTransferToSavingsConfirm(channels = emptyList(), withdrawSpark = true) + assertFalse(sut.isSparkWithdrawFailed.value) + } + + @Test + fun `transfer to savings with channels and spark moves both`() = test { + sparkState.value = SparkState(balanceSats = SPARK_SATS) + sut.onTransferToSavingsConfirm(channels = listOf(CHANNEL_A, CHANNEL_B), withdrawSpark = true) + + assertTrue(sut.closeSelectedChannels().isEmpty()) + verify(sparkRepo).withdrawAll(CLAIM_ADDRESS) + verify(lightningRepo).closeChannel(CHANNEL_A, false, null) + verify(lightningRepo).closeChannel(CHANNEL_B, false, null) + } + + @Test + fun `transfer to savings with only spark ignores a quote left from a channel selection`() = test { + balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) + whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) + advanceUntilIdle() + assertNotNull(sut.savingsSwapState.value.quote) + + sut.loadSavingsSwapQuote(0uL, 0uL) + advanceUntilIdle() + assertNull(sut.savingsSwapState.value.quote) + + sut.onTransferToSavingsConfirm(channels = emptyList(), withdrawSpark = true) + assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) + } + + @Test + fun `swap to savings withdraws spark only when it is selected`() = test { + stubSavingsSwapHappyPath() + sparkState.value = SparkState(balanceSats = SPARK_SATS) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) + advanceUntilIdle() + + sut.onTransferToSavingsConfirm(channels = listOf(CHANNEL_A), withdrawSpark = false) + assertEquals(SavingsTransferMode.SWAP, sut.savingsTransferMode.value) + sut.startSavingsSwap() + advanceUntilIdle() + + verify(sparkRepo, never()).withdrawAll(any()) + verify(lightningRepo, never()).closeChannel(any(), any(), anyOrNull()) + } + + @Test + fun `loadSparkWithdrawFee prices spark's withdrawal once per balance`() = test { + whenever(sparkRepo.estimateWithdrawAllFeeSats(CLAIM_ADDRESS)).thenReturn(Result.success(SPARK_FEE)) + + sut.loadSparkWithdrawFee(SPARK_SATS) + advanceUntilIdle() + sut.loadSparkWithdrawFee(SPARK_SATS) + advanceUntilIdle() + + assertEquals(SPARK_FEE, sut.sparkWithdrawFeeSats.value) + verify(sparkRepo, times(1)).estimateWithdrawAllFeeSats(CLAIM_ADDRESS) + } + + @Test + fun `loadSparkWithdrawFee gives up on an estimate that never returns`() = test { + var calls = 0 + whenever(sparkRepo.estimateWithdrawAllFeeSats(CLAIM_ADDRESS)).doSuspendableAnswer { + if (calls++ == 0) awaitCancellation() + Result.success(SPARK_FEE) + } + + sut.loadSparkWithdrawFee(SPARK_SATS) + advanceTimeBy(16.seconds) + runCurrent() + + assertNull(sut.sparkWithdrawFeeSats.value) + sut.loadSparkWithdrawFee(SPARK_SATS) + advanceUntilIdle() + assertEquals(SPARK_FEE, sut.sparkWithdrawFeeSats.value) + } + @Test fun `loadSavingsSwapQuote defaults to max transferable within limits and spendable balance`() = test { balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() val state = sut.savingsSwapState.value @@ -97,20 +244,31 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(expectedMax - expectedSwapFee - SWAP_MINER_FEE, quote.receiveSat) } + @Test + fun `loadSavingsSwapQuote sizes the swap by the selected channels, not spark`() = test { + balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) + whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) + + sut.loadSavingsSwapQuote(REQUESTED_SAT, channelSendableSat = CHANNEL_SENDABLE) + advanceUntilIdle() + + assertEquals(CHANNEL_SENDABLE - CHANNEL_SENDABLE / 100uL, sut.savingsSwapState.value.maxSat) + } + @Test fun `loadSavingsSwapQuote falls back to close when below the swap minimum`() = test { whenever(context.getString(R.string.lightning__savings_confirm__amount_too_low)).thenReturn(TOO_LOW) balanceState.value = BalanceState(maxSendLightningSats = SWAP_MIN - 1uL) whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() val state = sut.savingsSwapState.value assertNull(state.quote) assertEquals(0uL, state.maxSat) - sut.onTransferToSavingsConfirm(emptyList()) + sut.onTransferToSavingsConfirm(emptyList(), withdrawSpark = false) assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) sut.startSavingsSwap() @@ -124,13 +282,13 @@ class TransferViewModelTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) whenever(boltzService.reverseLimits(anyOrNull())).thenAnswer { throw AppError(BOLTZ_ERROR) } - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() assertNull(sut.savingsSwapState.value.quote) assertFalse(sut.savingsSwapState.value.isLoading) - sut.onTransferToSavingsConfirm(emptyList()) + sut.onTransferToSavingsConfirm(emptyList(), withdrawSpark = false) assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) } @@ -139,13 +297,13 @@ class TransferViewModelTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) whenever(boltzService.isSwapSupported).thenReturn(false) - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() assertEquals(SavingsSwapUiState(), sut.savingsSwapState.value) verify(boltzService, never()).reverseLimits(anyOrNull()) - sut.onTransferToSavingsConfirm(emptyList()) + sut.onTransferToSavingsConfirm(emptyList(), withdrawSpark = false) assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) } @@ -154,13 +312,13 @@ class TransferViewModelTest : BaseUnitTest() { balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) whenever(boltzService.isSwapEnabled()).thenReturn(false) - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() assertEquals(SavingsSwapUiState(), sut.savingsSwapState.value) verify(boltzService, never()).reverseLimits(anyOrNull()) - sut.onTransferToSavingsConfirm(emptyList()) + sut.onTransferToSavingsConfirm(emptyList(), withdrawSpark = false) assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) } @@ -168,13 +326,13 @@ class TransferViewModelTest : BaseUnitTest() { fun `onTransferToSavingsConfirm swaps when a quote is ready and closes when the user opts out`() = test { balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() - sut.onTransferToSavingsConfirm(emptyList()) + sut.onTransferToSavingsConfirm(listOf(CHANNEL_A), withdrawSpark = false) assertEquals(SavingsTransferMode.SWAP, sut.savingsTransferMode.value) - sut.onTransferToSavingsConfirm(emptyList(), SavingsTransferMode.CLOSE) + sut.onTransferToSavingsConfirm(listOf(CHANNEL_A), withdrawSpark = false, SavingsTransferMode.CLOSE) assertEquals(SavingsTransferMode.CLOSE, sut.savingsTransferMode.value) } @@ -182,13 +340,13 @@ class TransferViewModelTest : BaseUnitTest() { fun `onTransferToSavingsConfirm clears the outcome of an earlier swap`() = test { stubSavingsSwapHappyPath() whenever(lightningRepo.payInvoice(any(), anyOrNull())).thenReturn(Result.failure(AppError(PAY_ERROR))) - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() sut.startSavingsSwap() advanceUntilIdle() assertEquals(SavingsSwapResult.Failure(PAY_ERROR), sut.savingsSwapResult.value) - sut.onTransferToSavingsConfirm(emptyList()) + sut.onTransferToSavingsConfirm(emptyList(), withdrawSpark = false) assertNull(sut.savingsSwapResult.value) } @@ -197,7 +355,7 @@ class TransferViewModelTest : BaseUnitTest() { fun `onSwapAmountChange clamps to the allowed range and reprices`() = test { balanceState.value = BalanceState(maxSendLightningSats = SPENDABLE_LN) whenever(boltzService.reverseLimits(anyOrNull())).thenReturn(reverseLimits()) - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() sut.onSwapAmountChange(SWAP_MIN - 10_000uL) @@ -208,7 +366,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `startSavingsSwap succeeds when the claim event arrives`() = test { stubSavingsSwapHappyPath() - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() sut.startSavingsSwap() @@ -222,7 +380,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `startSavingsSwap fails when the swap reports an error event`() = test { stubSavingsSwapHappyPath() - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() sut.startSavingsSwap() @@ -236,7 +394,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `startSavingsSwap returns pending when the claim does not arrive in time`() = test { stubSavingsSwapHappyPath() - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() sut.startSavingsSwap() @@ -251,7 +409,7 @@ class TransferViewModelTest : BaseUnitTest() { fun `startSavingsSwap fails when the invoice payment fails`() = test { stubSavingsSwapHappyPath() whenever(lightningRepo.payInvoice(any(), anyOrNull())).thenReturn(Result.failure(AppError(PAY_ERROR))) - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() sut.startSavingsSwap() @@ -263,7 +421,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `startSavingsSwap ignores a second start while a swap is in flight`() = test { stubSavingsSwapHappyPath() - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() sut.startSavingsSwap() @@ -283,7 +441,7 @@ class TransferViewModelTest : BaseUnitTest() { stubSavingsSwapHappyPath() whenever(context.getString(R.string.wallet__payment_route_not_found)) .thenReturn(ROUTE_NOT_FOUND_MSG) - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() sut.startSavingsSwap() @@ -304,7 +462,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `startSavingsSwap ignores a restart once an outcome is already delivered`() = test { stubSavingsSwapHappyPath() - sut.loadSavingsSwapQuote(REQUESTED_SAT) + sut.loadSavingsSwapQuote(REQUESTED_SAT, balanceState.value.maxSendLightningSats) advanceUntilIdle() sut.startSavingsSwap() @@ -349,6 +507,11 @@ class TransferViewModelTest : BaseUnitTest() { ) private companion object { + val CHANNEL_A = createChannelDetails().copy(channelId = "channel_a") + val CHANNEL_B = createChannelDetails().copy(channelId = "channel_b") + const val SPARK_SATS = 40_000uL + const val SPARK_FEE = 1_470uL + const val CHANNEL_SENDABLE = 60_000uL const val TXID = "tx-abc" const val SPENDABLE_LN = 150_000uL const val REQUESTED_SAT = 200_000uL diff --git a/changelog.d/next/1697.added.md b/changelog.d/next/1697.added.md new file mode 100644 index 0000000000..e1e6a1d79d --- /dev/null +++ b/changelog.d/next/1697.added.md @@ -0,0 +1 @@ +Spending keeps Lightning without channels: transfers from Savings now fund a Spark wallet that sends and receives Lightning payments. diff --git a/docs/screens-map.md b/docs/screens-map.md index 7dfb7bf5df..b46984af02 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -135,6 +135,12 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | 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 | +| 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 | +| TransferLearnMoreScreen.kt | Transfer to Spending Balance From Savings (Channel Opening) › Transfer to Spending Receiving Capacity | ## ui/screens/transfer/external @@ -150,6 +156,8 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | +| 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 @@ -308,6 +316,7 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | ChannelDetailScreen.kt | Settings > Advanced › Connection details | | CloseConnectionScreen.kt | Settings > Advanced › Close connection | | LightningConnectionsScreen.kt | Settings > Advanced › Lightning Connections overview | +| SparkDetailScreen.kt | `todo` | ## ui/settings/pin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9767d1a465..a899b0db87 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc11" } +breez-sdk-spark = { module = "breez_sdk_spark:bindings-android", version = "0.27.2" } paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc2" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } diff --git a/journeys/README.md b/journeys/README.md index 5770c36ac8..93e69007f4 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -184,6 +184,7 @@ 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. | @@ -196,6 +197,8 @@ 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; closing the received sheet re-sizes iOS's Spending Confirm, so the lower amounts can show before the swipe | +| `transfer/savings-select-funds.xml` | same file and steps — iOS runs it as J8 on synonymdev/bitkit-ios#1128 | | — | `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 cf68388bcf..c124b846f7 100644 --- a/journeys/amount-limits/README.md +++ b/journeys/amount-limits/README.md @@ -2,7 +2,8 @@ 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 both amount-entry screens (Send, External node). +`MaxExceeded` effect path backs all three amount-entry screens (Send, Transfer→Spending, +External node). ## What the feature does - Typing a digit that would push the amount **over the cap is rejected** — the display stays at @@ -19,7 +20,10 @@ behaviour added on `fix/block-input-over-max`. The same `AmountInputViewModel.se - 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. **External-node flow needs a reachable LN peer.** The staging LSP node works as the peer: +2. **Transfer→Spending needs Spark connected** so the most Savings can send to the Spark deposit + address can be priced. The number pad is disabled until that max loads — **wait for it** before + typing. +3. **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`). @@ -40,6 +44,9 @@ behaviour added on `fix/block-input-over-max`. The same `AmountInputViewModel.se - 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`. - 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-over-max.xml b/journeys/amount-limits/transfer-spending-over-max.xml new file mode 100644 index 0000000000..9e1a555c31 --- /dev/null +++ b/journeys/amount-limits/transfer-spending-over-max.xml @@ -0,0 +1,24 @@ + + + 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 Spark connected on + its regtest, so the most Savings can send to the Spark deposit address can be priced. The pad is + disabled until that maximum loads — wait for it. The maximum is the Savings balance less the network + fee, so it is lower than the Savings balance. 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 new file mode 100644 index 0000000000..03e299c823 --- /dev/null +++ b/journeys/amount-limits/transfer-spending-preset-delete.xml @@ -0,0 +1,46 @@ + + + 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, and Spark connected + on its regtest. 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 `maxSats` — the Savings balance less the network fee to the Spark deposit address + (SparkFundingViewModel.refreshMax, 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/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index ae12fd1e29..67aaf59e71 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -76,11 +76,14 @@ 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 into Spark plus one scoped hardware Transfer activity | +| `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 new file mode 100644 index 0000000000..091f42d0e4 --- /dev/null +++ b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml @@ -0,0 +1,74 @@ + + + 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 Spark connected on its regtest, 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 fee estimate + + + 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 setting-up progress screen (testTag "LightningSettingUp"), 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-node-warmup.xml b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml new file mode 100644 index 0000000000..db2076b6b5 --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml @@ -0,0 +1,37 @@ + + + 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 Spark connected on its regtest and 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 the fee estimate 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 new file mode 100644 index 0000000000..c24949172e --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -0,0 +1,59 @@ + + + Drives the watch-only Transfer To Spending flow for a paired Trezor into Spark: Amount -> Sign With + Your Device -> Transaction Signed -> setting-up progress, then verifies the new transfer is + represented once in the hardware wallet's Core activity scope. Requires Spark connected on its + regtest and 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, SPENDING FEE, TO SPENDING and TOTAL cells, the Instant and Later rows, the Learn More button, and the Trezor illustration + + + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect"). If the fees rose since they were shown, verify a "Fees changed" toast, 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 setting-up progress screen appears (testTag "LightningSettingUp") + + + 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, above the older blue received row + + + Tap the new blue Transfer row and verify its detail screen shows "TO SPENDING" + + + diff --git a/journeys/transfer/savings-select-funds.xml b/journeys/transfer/savings-select-funds.xml new file mode 100644 index 0000000000..3663026dca --- /dev/null +++ b/journeys/transfer/savings-select-funds.xml @@ -0,0 +1,30 @@ + + + Verifies that Transfer to Savings lets the user pick what moves: each open channel and Spark are + rows on the Select funds list, all selected by default, and the confirm screen shows only what is + selected, with Spark's withdrawal fee only while Spark is selected. With a single item (Spark + alone, or one channel without a Spark balance) the confirm screen shows no Advanced button and + moves everything, as before Spark. + + Precondition: onboarded dev wallet on Spark's regtest chain (see the PR's Setup) with a Spark + balance and at least one open channel. Spark's regtest has no Lightning peers, so a channel there + needs a staged channel; on Blocktank's regtest the Spark balance cannot be funded. Start on the + wallet home screen. The journey stops before the swipe. + + + Tap the Spending balance card (testTag "ActivitySpending") + Tap "Transfer To Savings" (testTag "TransferToSavings") + If the transfer to savings intro appears, tap "Get Started" (testTag "SavingsIntro-button") + Tap "Continue" (testTag "AvailabilityContinue") on the funds availability screen + On the confirm screen, verify the amount is the channels' balance plus the Spark balance, a "Spark fee" amount shows, and an "Advanced" button shows + Tap "Advanced" + Verify the "Select funds to transfer" screen lists a "Spark" row with the Spark balance and one "Connection N" row per open channel, every switch on, and "Total selected" equals the confirm amount + Turn off the last "Connection N" row and verify "Total selected" drops by that row's amount + Tap "Continue" and verify the confirm amount equals the new total, "Spark fee" still shows, and the button reads "Transfer all" + Tap "Transfer all", then tap "Advanced", turn off the "Spark" row and every "Connection N" row, and verify "Continue" is disabled + Turn on the "Spark" row only and tap "Continue" + Verify the confirm amount equals the Spark balance and "Spark fee" shows + Tap "Transfer all" and verify the amount returns to the channels' balance plus the Spark balance, and the button reads "Advanced" + Tap "Advanced", turn off the "Spark" row, tap "Continue", and verify the amount equals the channels' balance with no "Spark fee" + + diff --git a/journeys/transfer/spark-transfer-to-spending.xml b/journeys/transfer/spark-transfer-to-spending.xml new file mode 100644 index 0000000000..a5c2a9f0c3 --- /dev/null +++ b/journeys/transfer/spark-transfer-to-spending.xml @@ -0,0 +1,29 @@ + + + Verifies the transfer to spending flow on Spark. With Spark ready, the Spending suggestion card opens the + "Spending Balance" intro once, then Funding, which leads with "Transfer from Savings" (testTag "FundTransfer") + and "Use Other Wallet" (testTag "FundReceive") above "Manual Setup" (testTag "FundManual"). Transfer from + Savings opens the "Transfer to spending" intro once, then the amount screen (testTag "SpendingAmount"). The + confirm screen shows "Network fees", "Spending fee", "To spending" and "Total", and the Instant and Later rows: + Instant shows its fee as "up to", Later as "≈", and picking a row updates the Spending fee and To spending + tiles. Learn more explains Instant and Later. Whether the provider offers Instant needs a real deposit Spark + sees, so this journey stops before the swipe; the states after it are covered by SettingUpScreen previews and + SparkRepoTest. + + Precondition: dev build with Spark connected on its regtest (no key needed), a freshly onboarded wallet (so + neither intro has been seen) with a positive on-chain Savings balance and no channels, and the Spending + suggestion card on Home. Start on the wallet home screen. + + + Tap the Spending suggestion card and verify the "Spending Balance" intro opens; tap "Get Started" (testTag "TransferIntro-button") + Verify "Transfer from Savings" (testTag "FundTransfer") and "Use Other Wallet" (testTag "FundReceive") are enabled above "Manual Setup" (testTag "FundManual") + Tap "Transfer from Savings" and verify the "Transfer to spending" intro opens; tap "Get Started" (testTag "SpendingIntro-button") + Verify the amount screen (testTag "SpendingAmount") opens and wait until the available amount (testTag "SpendingAmountAvailable") shows a positive value + Tap "25%" (testTag "SpendingAmountQuarter") and verify the amount is a quarter of the available amount; tap "Max" (testTag "SpendingAmountMax") and verify it equals the available amount + Tap "Continue" (testTag "SpendingAmountContinue") and verify the confirm screen shows "Network fees", "Spending fee", "To spending" and "Total", with "Instant" (testTag "SpendingConfirmInstant") selected and its fee shown as "up to" + Tap "Later" (testTag "SpendingConfirmLater") and verify the Spending fee shows "≈" with a lower amount and "To spending" grows by the difference + Tap "Learn More" (testTag "SpendingConfirmMore"), verify the "Transfer speed" screen with the spending balance bar, and tap "Understood" (testTag "LiquidityContinue") + Verify the confirm screen still shows Later selected; go back to the wallet home + Tap the Savings balance card (testTag "ActivitySavings"), tap "Transfer To Spending" (testTag "TransferToSpending") and verify the amount screen opens directly, with no intro + + diff --git a/journeys/transfer/spending-confirm-amount-change.xml b/journeys/transfer/spending-confirm-amount-change.xml new file mode 100644 index 0000000000..5343ebb898 --- /dev/null +++ b/journeys/transfer/spending-confirm-amount-change.xml @@ -0,0 +1,33 @@ + + + Verifies that a transfer to spending never pays more than the total Spending Confirm shows + (synonymdev/bitkit-android#1361). The transfer is priced again at swipe. A price that costs the same + or less pays on that swipe, at the new amount. A price whose total is higher, or that leaves less for + the spending balance, 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, + or a fee rate rise, 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 transfer priced at swipe spends one input and + pays a lower network fee, at once. + + Precondition: a freshly onboarded dev wallet with Spark connected on its regtest, 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" (testTag "SpendingConfirmNetworkFee") and "Total" (testTag "SpendingConfirmTotal") + 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 + Verify no "Fees changed" toast appears and the setting up screen (testTag "LightningSettingUp") opens within 90s + Tap "Continue Using Bitkit" (testTag "TransferSuccess-button"), open the newest transfer in the activity list and verify its fee is lower than the "Network Fees" amount noted + + diff --git a/journeys/transfer/transfer-unavailable-sheet.xml b/journeys/transfer/transfer-unavailable-sheet.xml index e3fe769fee..1f5a9722b5 100644 --- a/journeys/transfer/transfer-unavailable-sheet.xml +++ b/journeys/transfer/transfer-unavailable-sheet.xml @@ -8,7 +8,9 @@ (testTag "TransferUnavailableContinue"), which opens the existing Manual Setup screen. Receive's Lightning path shows the same sheet content. - Precondition: dev build, onboarded wallet with a positive on-chain Savings balance and no channels. Start on + Precondition: dev build, onboarded wallet with a positive on-chain Savings balance, no channels and Spark + not running (a build without a Breez API key, or Spark's regtest unreachable); with Spark ready, see + spark-transfer-to-spending.xml. Start on the wallet home screen. diff --git a/scripts/spark-regtest-esplora.py b/scripts/spark-regtest-esplora.py new file mode 100755 index 0000000000..d5a3863116 --- /dev/null +++ b/scripts/spark-regtest-esplora.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Esplora endpoint on Spark's regtest chain for a dev build's ldk-node. + +Spark's regtest is Lightspark's own chain. Its only public chain API is a mempool.space instance +(the Breez SDK's regtest default), which answers Esplora's address, transaction and block calls +but has no `/fee-estimates`, which ldk-node reads at start, nor `/block//status`, which its +Lightning sync reads. This proxy serves `/fee-estimates` from the instance's `/v1/fees/recommended`, +builds a block's status from `/block/` and `/block-height/`, and forwards everything +else, so a dev build built with `ESPLORA_URL=http://:` runs on Spark's chain. + +Usage: + python3 scripts/spark-regtest-esplora.py [--port 3999] [--bind 0.0.0.0] [--max-seconds 10800] + # emulator on the same host: ESPLORA_URL=http://10.0.2.2:3999 +""" +import argparse +import json +import threading +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +UPSTREAM = "https://regtest-mempool.us-west-2.sparkinfra.net/api" + + +def log(*parts): + print(time.strftime("%H:%M:%S"), *parts, flush=True) + + +def fetch(path): + with urllib.request.urlopen(f"{UPSTREAM}{path}", timeout=15) as response: + return response.read().decode() + + +def block_status(block_hash): + height = json.loads(fetch(f"/block/{block_hash}"))["height"] + in_best_chain = fetch(f"/block-height/{height}").strip() == block_hash + try: + next_best = fetch(f"/block-height/{height + 1}").strip() if in_best_chain else None + except urllib.error.HTTPError: + next_best = None + return {"in_best_chain": in_best_chain, "height": height, "next_best": next_best} + + +def fee_estimates(): + with urllib.request.urlopen(f"{UPSTREAM}/v1/fees/recommended", timeout=15) as response: + fees = json.load(response) + targets = {1: "fastestFee", 2: "fastestFee", 3: "halfHourFee", 6: "hourFee", 12: "economyFee", 144: "minimumFee"} + return {str(blocks): float(fees.get(key, 1)) for blocks, key in targets.items()} + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.forward() + + def do_POST(self): + self.forward() + + def forward(self): + if self.path.rstrip("/") == "/fee-estimates": + return self.reply(200, json.dumps(fee_estimates()).encode(), "application/json") + parts = self.path.strip("/").split("/") + if len(parts) == 3 and parts[0] == "block" and parts[2] == "status": + return self.reply(200, json.dumps(block_status(parts[1])).encode(), "application/json") + body = self.rfile.read(int(self.headers.get("Content-Length") or 0)) if self.command == "POST" else None + request = urllib.request.Request(f"{UPSTREAM}{self.path}", data=body, method=self.command) + if self.headers.get("Content-Type"): + request.add_header("Content-Type", self.headers["Content-Type"]) + try: + with urllib.request.urlopen(request, timeout=30) as response: + self.reply(response.status, response.read(), response.headers.get("Content-Type")) + except urllib.error.HTTPError as error: + self.reply(error.code, error.read(), error.headers.get("Content-Type")) + + def reply(self, status, payload, content_type): + self.send_response(status) + if content_type: + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + log(self.command, self.path, *args[1:2]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--port", type=int, default=3999) + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--max-seconds", type=int, default=3 * 3600) + args = parser.parse_args() + server = ThreadingHTTPServer((args.bind, args.port), Handler) + threading.Timer(args.max_seconds, server.shutdown).start() + log(f"serving Spark regtest Esplora on {args.bind}:{args.port} for {args.max_seconds}s") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/spark-regtest-fund.py b/scripts/spark-regtest-fund.py new file mode 100755 index 0000000000..20a3f7de16 --- /dev/null +++ b/scripts/spark-regtest-fund.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Fund an address on Spark's regtest chain from Lightspark's regtest faucet. + +Use it on a dev build running on Spark's chain (see `spark-regtest-esplora.py`): fund the Savings +address to test Savings to Spending and the deposit claim, or Spark's deposit address on the Spark +screen to fund Spending directly. Lightspark's API faucet takes HTTP basic auth: set +FAUCET_USERNAME and FAUCET_PASSWORD (issued by Lightspark). The web faucet at +https://app.lightspark.com/regtest-faucet works without them, by hand. + +Usage: + FAUCET_USERNAME=… FAUCET_PASSWORD=… python3 scripts/spark-regtest-fund.py [sats] +""" +import base64 +import json +import os +import sys +import urllib.request + +FAUCET_URL = os.environ.get("FAUCET_URL", "https://api.lightspark.com/graphql/spark/rc") +QUERY = ( + "mutation RequestRegtestFunds($address: String!, $amount_sats: Long!) " + "{ request_regtest_funds(input: {address: $address, amount_sats: $amount_sats}) { transaction_hash } }" +) + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + address = sys.argv[1] + amount_sats = int(sys.argv[2]) if len(sys.argv) > 2 else 100_000 + username, password = os.environ.get("FAUCET_USERNAME"), os.environ.get("FAUCET_PASSWORD") + if not username or not password: + sys.exit("Set FAUCET_USERNAME and FAUCET_PASSWORD, or use https://app.lightspark.com/regtest-faucet") + body = json.dumps({ + "operationName": "RequestRegtestFunds", + "variables": {"address": address, "amount_sats": amount_sats}, + "query": QUERY, + }).encode() + request = urllib.request.Request(FAUCET_URL, data=body, method="POST") + request.add_header("Content-Type", "application/json") + request.add_header("Authorization", "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode()) + with urllib.request.urlopen(request, timeout=60) as response: + result = json.load(response) + if result.get("errors"): + sys.exit("Faucet returned errors: " + ", ".join(e["message"] for e in result["errors"])) + print(result["data"]["request_regtest_funds"]["transaction_hash"]) + + +if __name__ == "__main__": + main() diff --git a/settings.gradle.kts b/settings.gradle.kts index 00170049a1..c9570f5271 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -87,6 +87,10 @@ dependencyResolutionManagement { password = pass } } + maven { + url = uri("https://mvn.breez.technology/releases") + content { includeGroup("breez_sdk_spark") } + } maven { url = uri("https://maven.pkg.github.com/pubky/paykit-rs") credentials {