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 b0140d5d77..451d0593fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -357,6 +357,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()) @@ -564,6 +566,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/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 40427869ec..ad85c263a8 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 const val isGeoblockingEnabled = BuildConfig.GEO @@ -219,6 +227,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 0181b711e7..6b5df079bd 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -38,6 +38,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 c52781198c..5c150a5eb1 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -86,3 +86,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 cf99346ba0..a00a640884 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -18,8 +18,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 @@ -62,6 +64,7 @@ import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.MetadataBackupV1 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 @@ -128,6 +131,7 @@ class BackupRepo @Inject constructor( private val onchainSendAttemptStore: OnchainSendAttemptStore, private val transferRepo: Provider, private val lightningRepo: Provider, + private val sparkRepo: SparkRepo, private val vssStoreIdProvider: VssStoreIdProvider, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, @@ -405,6 +409,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 { @@ -543,6 +561,8 @@ class BackupRepo @Inject constructor( val upload = if (category == BackupCategory.WALLET) { uploadWalletBackup(null) + } else if (category == BackupCategory.SPARK_EXIT) { + uploadSparkExitBackup() } else { runSuspendCatching { vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)).getOrThrow() @@ -572,6 +592,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(usdt: String? = null): Result = uploadWalletBackup(usdt).onFailure { markBackupRequired(BackupCategory.WALLET) } @@ -672,6 +701,11 @@ class BackupRepo @Inject constructor( } BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node") + + BackupCategory.SPARK_EXIT -> { + val backup = checkNotNull(sparkRepo.exitBackup().getOrThrow()) { "Spark is not connected" } + json.encodeToString(backup).toByteArray() + } } private suspend fun getMetadataBackupDataBytes(): ByteArray = withContext(ioDispatcher) { @@ -843,6 +877,11 @@ class BackupRepo @Inject constructor( if (restored.needsRewrite) categoriesNeedingRewrite += BackupCategory.ACTIVITY restored.createdAt } + performRestore(BackupCategory.SPARK_EXIT) { dataBytes -> + val parsed = json.decodeFromString(String(dataBytes)) + sparkRepo.restoreExitBackup(parsed, backupWalletIndex()).getOrThrow() + parsed.createdAt + } Logger.info("Full restore success", context = TAG) }.onSuccess { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index db09d7ac9e..404bcf046d 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -48,6 +48,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 @@ -55,6 +56,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 @@ -67,6 +69,7 @@ import to.bitkit.data.keychain.Keychain 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 @@ -78,6 +81,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 @@ -128,6 +132,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() @@ -173,6 +178,7 @@ class LightningRepo @Inject constructor( init { observeConnectivityForSyncRetry() + scope.launch { sparkRepo.nodeEvents.collect { onSparkEvent(it) } } } private fun observeConnectivityForSyncRetry() { @@ -352,6 +358,9 @@ class LightningRepo @Inject constructor( // The geocheck needs no node, so screens gated on it are right before the node is up. scope.launch { updateGeoBlockState() } + // 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) @@ -601,6 +610,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() @@ -913,6 +930,7 @@ class LightningRepo @Inject constructor( Logger.debug("node stopped, calling wipeStorage", context = TAG) lastKnownNodeId = null lightningService.wipeStorage(walletIndex) + sparkRepo.wipe(walletIndex) clearProbeOutcomes() _lightningState.update { LightningState( @@ -1307,6 +1325,9 @@ class LightningRepo @Inject constructor( expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoice") { updateGeoBlockState() + if (shouldReceiveWithSpark(amountSats)) { + return@executeWhenNodeRunning sparkRepo.createInvoice(amountSats, description, expirySeconds) + } runCatching { lightningService.receive(amountSats, description, expirySeconds) } } @@ -1316,9 +1337,34 @@ class LightningRepo @Inject constructor( expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoiceMsats") { updateGeoBlockState() + 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, @@ -1410,6 +1456,20 @@ class LightningRepo @Inject constructor( paymentDeadlineAt: Instant?, maxTotalRoutingFeeMsat: ULong?, onBeforeSend: suspend () -> Boolean, + ): Result { + if (shouldPayWithSpark(bolt11, sats)) { + if (!onBeforeSend()) return Result.failure(PaymentAbortedBeforeSend()) + return sparkRepo.payInvoice(bolt11, sats) + } + 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") { waitForUsableChannels() if (!onBeforeSend()) return@executeWhenNodeRunning Result.failure(PaymentAbortedBeforeSend()) @@ -1418,6 +1478,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() } @@ -1863,9 +1934,10 @@ class LightningRepo @Inject constructor( } } - suspend fun completeAcceptedTransferFollowup(orderId: String, txid: String) { - val attempt = onchainSendAttemptStore.current() - if (attempt?.orderId == orderId && attempt.txid.equals(txid, ignoreCase = true) && + /** 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) @@ -2191,6 +2263,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 } @@ -2236,9 +2309,13 @@ class LightningRepo @Inject constructor( fun getChannels(): List? = if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.channels else null + /** + * 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( @@ -2306,6 +2383,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) @@ -2316,6 +2394,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) @@ -2610,6 +2691,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/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt new file mode 100644 index 0000000000..b5cf839834 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -0,0 +1,370 @@ +package to.bitkit.repositories + +import breez_sdk_spark.FeePolicy +import breez_sdk_spark.Payment +import breez_sdk_spark.PaymentDetails +import breez_sdk_spark.PaymentMethod +import breez_sdk_spark.PaymentStatus +import breez_sdk_spark.SdkEvent +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.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.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.SettingsStore +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.services.SparkService +import to.bitkit.utils.Logger +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +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 settingsStore: SettingsStore, +) { + companion object { + private const val TAG = "SparkRepo" + private const val MSATS_PER_SAT = 1_000uL + + /** + * Size of Spark's deposit claim transaction; the SDK prices a standard claim as the fastest recommended fee + * rate times this size before the provider quotes it (breez/spark-sdk `CLAIM_TX_SIZE_VBYTES`). + */ + private const val CLAIM_TX_SIZE_VBYTES = 99uL + + /** Last step of the Spending setup progress, shown once a funding deposit is claimed. */ + private const val SETUP_STEP_DONE = 3 + } + + private val scope = appScope(bgDispatcher, TAG) + + /** Deposits this app broadcast that Spark hasn't listed yet, by txid, so they show as pending at once. */ + private val broadcastDeposits = ConcurrentHashMap() + private var eventsJob: Job? = null + + 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) } + } + + 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.balanceSats >= 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 + val bolt11 = method as SendPaymentMethod.Bolt11Invoice + bolt11.sparkTransferFeeSats?.takeIf { sparkService.prefersSpark } ?: bolt11.lightningFeeSats + } + } + + /** Pays [bolt11] and returns its payment hash, the id ldk-node payments use, so the send flow can match it. */ + suspend fun payInvoice(bolt11: String, amountSats: ULong?): Result = withContext(bgDispatcher) { + runSuspendCatching { + val prepared = sparkService.prepareSend(bolt11, amountSats) + val paymentHash = (prepared.paymentMethod as SendPaymentMethod.Bolt11Invoice).invoiceDetails.paymentHash + val payment = sparkService.send(prepared) + Logger.info("Sent Spark payment '${payment.id}' with status '${payment.status}'", context = TAG) + paymentHash + }.onFailure { + Logger.error("Failed to pay invoice with Spark", it, context = TAG) + } + } + + /** Estimates what Spark keeps from a deposit to claim it, the way the SDK does before the provider quotes it. */ + suspend fun estimateDepositClaimFeeSats(): Result = withContext(bgDispatcher) { + runSuspendCatching { sparkService.fastestFeeRate() * CLAIM_TX_SIZE_VBYTES } + } + + 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 prepared = sparkService.prepareSend(address, _state.value.balanceSats, FeePolicy.FEES_INCLUDED) + sparkService.send(prepared) + refresh() + }.onFailure { + Logger.error("Failed to withdraw from Spark", it, context = TAG) + } + } + + /** + * 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", + 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 claimed = sparkService.listPayments() + .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(), + recoverableFundsSats = info.recoverableFundsSats, + ).withBroadcastDeposits() + } + } + + private suspend fun syncActivities() { + sparkService.listPayments().forEach { recordActivity(it) } + } + + private suspend fun onSdkEvent(event: SdkEvent) { + runSuspendCatching { + when (event) { + is SdkEvent.Synced -> 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() + settingsStore.update { + if (it.lightningSetupStep > 0) it.copy(lightningSetupStep = SETUP_STEP_DONE) else it + } + } + 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 activity = LightningActivity.create( + id = lightning.htlcDetails.paymentHash, + 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, + ) + activityRepo.get().upsertActivity(Activity.Lightning(activity)) + } + + 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 + } + } +} + +data class SparkState( + val isSupported: Boolean = false, + val isReady: 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 recoverableFundsSats: ULong = 0uL, +) + +/** 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, +) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index c7c9173a01..5166c896b2 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 @@ -74,6 +76,7 @@ class WalletRepo @Inject constructor( private val transferRepo: TransferRepo, private val activityRepo: ActivityRepo, private val hwWalletRepo: HwWalletRepo, + private val sparkRepo: SparkRepo, ) { private val repoScope = appScope(bgDispatcher, TAG) @@ -112,6 +115,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( @@ -403,7 +416,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.balanceSats) + _balanceState.update { it.copy(maxSendLightningSats = maxSendSats) } } suspend fun syncBalances() { @@ -960,11 +974,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 6e3cf85e63..a6c9ca76d7 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 @@ -396,6 +397,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/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt new file mode 100644 index 0000000000..0d09d9b92b --- /dev/null +++ b/app/src/main/java/to/bitkit/services/SparkService.kt @@ -0,0 +1,207 @@ +package to.bitkit.services + +import breez_sdk_spark.BreezSdk +import breez_sdk_spark.ConnectRequest +import breez_sdk_spark.DepositInfo +import breez_sdk_spark.EventListener +import breez_sdk_spark.FeePolicy +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.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" + + /** 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 automatic deposit claim may pay on top. */ + private const val DEPOSIT_CLAIM_FEE_PPM = 2_000u + + /** + * 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 = defaultConfig(network).apply { + apiKey = Env.breezApiKey + maxDepositClaimFee = MaxFee( + onchain = MaxOnchainFee.NetworkRecommended(DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB), + proportionalPpm = DEPOSIT_CLAIM_FEE_PPM, + ) + } + 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 + + suspend fun listUnclaimedDeposits(): List = + requireSdk().listUnclaimedDeposits(ListUnclaimedDepositsRequest).deposits + + 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) : AppError(message) { + class Unsupported : SparkError("Spark is not available on this network or build") + class NotConnected : SparkError("Spark wallet is not connected") +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 639629c5d1..8d0da8b68e 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -203,6 +203,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 @@ -1147,6 +1148,8 @@ private fun RootNavHost( deepLinkableComposable { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady FundingScreen( onTransfer = { @@ -1161,7 +1164,7 @@ private fun RootNavHost( }, onManual = { navController.navigateTo(Routes.ExternalNav) }, onBackClick = { navController.popBackStack() }, - isGeoBlocked = isGeoBlocked, + isGeoBlocked = isTransferUnavailable, ) } deepLinkableComposable { @@ -1303,6 +1306,8 @@ private fun NavGraphBuilder.home( deepLinkableComposable { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady val onchainActivities by activityListViewModel.onchainActivities.collectAsStateWithLifecycle() val forceCloseRemainingDuration by appViewModel.forceCloseRemainingDuration.collectAsStateWithLifecycle() @@ -1312,7 +1317,7 @@ private fun NavGraphBuilder.home( onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { - if (isGeoBlocked) { + if (isTransferUnavailable) { appViewModel.showSheet(Sheet.TransferUnavailable) } else { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1326,11 +1331,14 @@ private fun NavGraphBuilder.home( val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady 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) }, @@ -1343,7 +1351,7 @@ private fun NavGraphBuilder.home( } }, onTransferFromSavingsClick = { - if (isGeoBlocked) { + if (isTransferUnavailable) { appViewModel.showSheet(Sheet.TransferUnavailable) } else { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1356,11 +1364,13 @@ private fun NavGraphBuilder.home( val walletId = it.toRoute().walletId val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() + val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle() + val isTransferUnavailable = isGeoBlocked && !sparkState.isReady HardwareWalletScreen( walletId = walletId, onActivityItemClick = { navController.navToActivityDetail(it) }, onTransferToSpendingClick = { selectedDeviceId -> - if (isGeoBlocked) { + if (isTransferUnavailable) { appViewModel.showSheet(Sheet.TransferUnavailable) } else { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) @@ -1954,6 +1964,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( @@ -2434,6 +2458,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 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..e2da9f207f 100644 --- a/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt +++ b/app/src/main/java/to/bitkit/ui/components/LightningChannel.kt @@ -39,6 +39,7 @@ 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 spendingAvailableColor = if (status == ChannelStatusUi.CLOSED) Colors.Gray3 else Colors.Purple @@ -58,7 +59,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 +81,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 +107,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 +136,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/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt index 4b18c10d88..d76fd1a4ca 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 @@ -86,7 +86,9 @@ fun SavingsConfirmScreen( val channels = selectedChannels ?: openChannels - val amount = channels.sumOf { it.amountOnClose } + val sparkState by wallet.sparkState.collectAsStateWithLifecycle() + val channelsAmount = channels.sumOf { it.amountOnClose } + val amount = channelsAmount + sparkState.balanceSats val swapState by transfer.savingsSwapState.collectAsStateWithLifecycle() @@ -96,8 +98,8 @@ fun SavingsConfirmScreen( } // Present the swap fee before the user commits. Recomputed when the amount changes. - LaunchedEffect(amount) { - if (amount > 0uL) transfer.loadSavingsSwapQuote(amount) + LaunchedEffect(channelsAmount) { + if (channelsAmount > 0uL) transfer.loadSavingsSwapQuote(channelsAmount) } Box { 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 index 9567fdbbd1..3d0c4afbae 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -180,7 +180,16 @@ private fun Content( .verticalScroll(rememberScrollState()) ) { val clientBalance = state.clientBalanceSat - val lspFee = state.feeSat.safe() - clientBalance.safe() + val lspFee = if (state.isSpark) { + state.sparkClaimFeeSats + } else { + state.feeSat.safe() - clientBalance.safe() + } + val toSpending = if (state.isSpark) { + clientBalance.safe() - state.sparkClaimFeeSats.safe() + } else { + clientBalance + } val total = state.confirmLeavingAmountSats val lspBalance = state.lspBalanceSat @@ -197,7 +206,13 @@ private fun Content( amount = miningFeeSats.toLong(), ) FeeInfo( - label = stringResource(R.string.lightning__spending_confirm__lsp_fee), + label = stringResource( + if (state.isSpark) { + R.string.lightning__spark_claim_fee + } else { + R.string.lightning__spending_confirm__lsp_fee + } + ), amount = lspFee.toLong(), ) } @@ -207,7 +222,7 @@ private fun Content( ) { FeeInfo( label = stringResource(R.string.lightning__spending_confirm__amount), - amount = clientBalance.toLong(), + amount = toSpending.toLong(), ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__total), @@ -229,42 +244,49 @@ private fun Content( VerticalSpacer(16.dp) } - SettingsSwitchRow( - title = stringResource(R.string.settings__bg__setup), - isChecked = hasNotificationPermission, - colors = AppSwitchDefaults.colorsPurple, - onClick = onSwitchClick, - switchTestTag = "SpendingConfirmNotificationSwitch", - modifier = Modifier.fillMaxWidth() - ) + // Blocktank's order-paid push and channel liquidity don't apply to funding Spark. + if (!state.isSpark) { + SettingsSwitchRow( + title = stringResource(R.string.settings__bg__setup), + isChecked = hasNotificationPermission, + colors = AppSwitchDefaults.colorsPurple, + onClick = onSwitchClick, + switchTestTag = "SpendingConfirmNotificationSwitch", + modifier = Modifier.fillMaxWidth() + ) - VerticalSpacer(16.dp) + VerticalSpacer(16.dp) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - PrimaryButton( - text = stringResource(R.string.common__learn_more), - size = ButtonSize.Small, - fullWidth = false, - onClick = onLearnMoreClick, - modifier = Modifier.testTag("SpendingConfirmMore") - ) - PrimaryButton( - text = stringResource( - if (isAdvanced) R.string.lightning__spending_confirm__default else R.string.common__advanced - ), - size = ButtonSize.Small, - fullWidth = false, - onClick = { - if (isAdvanced) { - onUseDefaultLspBalanceClick() - } else { - onAdvancedClick() - } - }, - modifier = Modifier.testTag( - if (isAdvanced) "SpendingConfirmDefault" else "SpendingConfirmAdvanced" + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PrimaryButton( + text = stringResource(R.string.common__learn_more), + size = ButtonSize.Small, + fullWidth = false, + onClick = onLearnMoreClick, + modifier = Modifier.testTag("SpendingConfirmMore") ) - ) + PrimaryButton( + text = stringResource( + if (isAdvanced) { + R.string.lightning__spending_confirm__default + } else { + R.string.common__advanced + } + ), + size = ButtonSize.Small, + fullWidth = false, + onClick = { + if (isAdvanced) { + onUseDefaultLspBalanceClick() + } else { + onAdvancedClick() + } + }, + modifier = Modifier.testTag( + if (isAdvanced) "SpendingConfirmDefault" else "SpendingConfirmAdvanced" + ) + ) + } } VerticalSpacer(16.dp) 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 654a0dcf43..f1b1a4cbae 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 @@ -41,6 +41,12 @@ class EditInvoiceVM @Inject constructor( isGeoBlocked: Boolean, ) { viewModelScope.launch { + if (walletRepo.canReceiveWithSpark()) { + editInvoiceEffect( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None) + ) + return@launch + } _isLoading.update { true } val inboundCapacitySats = walletRepo.inboundLiquiditySats() val maxCjitAmountSats = maxCjitAmountSats(source, amountSats, inboundCapacitySats, isGeoBlocked) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index f99e7a16c5..1edec88d00 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 @@ -123,6 +123,7 @@ fun ReceiveQrScreen( onVerifyHardwareAddress: () -> Unit = {}, showPaymentRequestContacts: Boolean = false, onClickPaymentRequestContacts: () -> Unit = {}, + canReceiveWithSpark: Boolean = false, ) { SetMaxBrightness() @@ -135,8 +136,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 b6b56c32fc..e01c880e88 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 @@ -92,6 +92,7 @@ fun ReceiveSheet( var editInvoiceSourceTab by remember { mutableStateOf(ReceiveTab.SAVINGS) } var isAdditionalLiquidityAmountEntry by remember { mutableStateOf(false) } 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 { @@ -157,6 +158,7 @@ fun ReceiveSheet( cjitInvoice = cjitSessionState.cjitInvoice, walletState = walletState, lightningState = lightningState, + canReceiveWithSpark = sparkState.isReady, onClickReceiveCjit = { if (lightningState.isGeoBlocked) { navController.navigateTo(ReceiveRoute.GeoBlock) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 2e3a75bb1f..b074892039 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 84ec1944a8..5fe58e1f7a 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,10 +53,12 @@ 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.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 @@ -69,6 +73,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 @@ -101,6 +106,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() }, @@ -115,6 +122,8 @@ private fun Content( onClickAddConnection: () -> Unit = {}, onClickExportLogs: () -> Unit = {}, onClickChannel: (ChannelUi) -> Unit = {}, + onClickSpark: () -> Unit = {}, + onOpenTx: (String) -> Unit = {}, onRefresh: () -> Unit = {}, ) { var showClosed by remember { mutableStateOf(false) } @@ -148,7 +157,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() @@ -159,7 +169,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)) @@ -181,9 +195,19 @@ 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)) + 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) @@ -275,7 +299,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, @@ -291,12 +315,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) @@ -309,7 +341,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) } } } @@ -380,6 +412,88 @@ 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, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clickableAlpha { onOpenTx(depositUi.deposit.txid) } + .testTag("SparkDeposit") + ) { + Column(modifier = Modifier.weight(1f)) { + CaptionB(text = stringResource(R.string.lightning__spark_deposit)) + CaptionB( + text = depositUi.deposit.txid, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.padding(start = 16.dp) + ) { + MoneyCaptionB(sats = depositUi.deposit.amountSats.toLong(), symbol = true) + depositUi.confirmations?.let { + CaptionB( + text = stringResource(R.string.lightning__spark_confirmations, minOf(it.toInt(), 3)), + color = Colors.Purple, + ) + } + } + } +} + @Composable private fun EmptyStateContent(modifier: Modifier = Modifier) { Column(modifier = modifier) { diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 74bc1d85be..07565a746f 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 @@ -23,9 +23,11 @@ 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 @@ -37,6 +39,9 @@ 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.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -50,6 +55,7 @@ class LightningConnectionsViewModel @Inject constructor( private val blocktankRepo: BlocktankRepo, private val logsRepo: LogsRepo, private val activityRepo: ActivityRepo, + private val sparkRepo: SparkRepo, ) : ViewModel() { companion object { @@ -96,9 +102,10 @@ class LightningConnectionsViewModel @Inject constructor( combine( lightningRepo.lightningState, blocktankRepo.blocktankState, - ) { lightningState, blocktankState -> - Pair(lightningState, blocktankState) - }.collect { (lightningState, blocktankState) -> + sparkRepo.state, + ) { lightningState, blocktankState, sparkState -> + Triple(lightningState, blocktankState, sparkState) + }.collect { (lightningState, blocktankState, sparkState) -> val channels = lightningState.channels val connectionText = context.getString(R.string.lightning__connection) @@ -114,8 +121,10 @@ class LightningConnectionsViewModel @Inject constructor( failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders) .map { it.mapToUiModel(channels, blocktankState.paidOrders, connectionText) } .reversed().toImmutableList(), - localBalance = calculateLocalBalance(channels), + localBalance = calculateLocalBalance(channels).safe() + sparkState.balanceSats.safe(), remoteBalance = channels.calculateRemoteBalance(), + spark = sparkState.toUi(lightningRepo), + isSparkReceiving = sparkState.isReady, ) } } @@ -149,6 +158,7 @@ class LightningConnectionsViewModel @Inject constructor( } suspend fun refreshObservedState() { + sparkRepo.refreshState() lightningRepo.sync() blocktankRepo.refreshOrders() loadClosedChannels() @@ -300,8 +310,37 @@ 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 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(), + ) +} + 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..6663e8dbc6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt @@ -0,0 +1,186 @@ +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.ui.components.BodyS +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.ChannelStatusUi +import to.bitkit.ui.components.FillHeight +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.fillMaxSize() + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .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) + }, + ) + } + } + + FillHeight() + VerticalSpacer(32.dp) + 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)), + ), + ), + ), + ) + } +} 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 6ae7003a66..e7014d0476 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 { @@ -56,16 +58,19 @@ 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. + maxSendLightningSats = maxOf(lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), sparkSats), maxSendOnchainSats = getMaxSendAmount(balanceDetails), balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), - balanceInTransferToSpending = toSpendingAmount, + balanceInTransferToSpending = toSpendingAmount.safe() + sparkRepo.state.value.pendingDepositSats.safe(), hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, ) 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/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 2c7aaab98c..e39130bb5b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -75,6 +75,7 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.OnchainTransferContext +import to.bitkit.repositories.SparkRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -107,6 +108,7 @@ class TransferViewModel @Inject constructor( private val boltzService: BoltzService, private val currencyRepo: CurrencyRepo, private val clock: Clock, + private val sparkRepo: SparkRepo, ) : ViewModel() { /** Paired hardware wallets, for screens that render vendor-specific device visuals. */ @@ -145,6 +147,7 @@ class TransferViewModel @Inject constructor( fun onConfirmAmount(satsAmount: Long) { if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return + if (isSparkFunding()) return onConfirmSparkAmount(satsAmount.toULong()) val values = blocktankRepo.calculateLiquidityOptions(satsAmount.toULong()).getOrNull() if (values == null || values.maxLspBalanceSat == 0uL) { setTransferEffect( @@ -197,6 +200,29 @@ class TransferViewModel @Inject constructor( } } + /** Blocktank no longer sells channels, so Spending is funded by a deposit into the Spark wallet. */ + private fun isSparkFunding(): Boolean = sparkRepo.state.value.isReady + + private fun onConfirmSparkAmount(amountSats: ULong) { + viewModelScope.launch { + if (!canFundOrder(amountSats)) { + setTransferEffect( + TransferEffect.ToastError( + title = context.getString(R.string.lightning__spending_amount__error_balance__title), + description = context.getString( + R.string.lightning__spending_amount__error_balance__description + ), + ) + ) + return@launch + } + onEstimateReady(clientBalanceSat = amountSats, lspBalanceSat = 0uL, feeSat = amountSats, isSpark = true) + sparkRepo.estimateDepositClaimFeeSats() + .onSuccess { fee -> _spendingUiState.update { it.copy(sparkClaimFeeSats = fee) } } + .onFailure { Logger.warn("Failed to estimate Spark claim fee", it, context = TAG) } + } + } + private suspend fun estimateSpendingFee( clientBalanceSat: ULong, lspBalanceSat: ULong, @@ -295,9 +321,14 @@ class TransferViewModel @Inject constructor( confirmFeeJob = viewModelScope.launch { _spendingUiState.update { it.copy(isConfirmFeeReady = false) } val order = _spendingUiState.value.order + val address = if (_spendingUiState.value.isSpark) { + sparkRepo.depositAddress().onFailure { ToastEventBus.send(it) }.getOrNull() + } else { + order?.fundingAddress ?: spendingSizingAddress() + } val target = SpendingFundingTarget( feeSat = _spendingUiState.value.feeSat, - address = order?.fundingAddress ?: spendingSizingAddress() ?: return@launch, + address = address ?: return@launch, orderId = order?.id, ) buildSpendingConfirmFundingPlan(target) @@ -337,6 +368,7 @@ class TransferViewModel @Inject constructor( confirmPayJob = viewModelScope.launch { try { val paid = runSuspendCatching { + if (state.isSpark) return@runSuspendCatching paySparkDeposit(state) val order = getOrCreateSpendingOrder() ?: return@runSuspendCatching false paySpendingConfirmOrder(order, shown = state) }.onFailure { @@ -355,6 +387,35 @@ class TransferViewModel @Inject constructor( } } + private suspend fun paySparkDeposit(shown: TransferToSpendingUiState): Boolean { + val plan = spendingConfirmFundingPlan?.takeIf { it.miningFeeSats > 0uL } ?: run { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__try_again)) + return false + } + val address = sparkRepo.depositAddress().getOrThrow() + val outcome = lightningRepo.sendOnChain( + address = address, + sats = shown.clientBalanceSat, + speed = TransactionSpeed.Fast, + utxosToSpend = if (plan.shouldUseSendAll) null else plan.selectedUtxos, + feeRates = plan.feeRates, + isTransfer = true, + isMaxAmount = plan.shouldUseSendAll, + ).getOrThrow() + if (outcome !is OnchainSendOutcome.Accepted) { + showRetainedFundingPending() + return false + } + withContext(NonCancellable) { + runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(orderId = null, txid = outcome.txid) } + .onFailure { Logger.warn("Failed to finish accepted Spark funding locally", it, context = TAG) } + } + sparkRepo.onDepositBroadcast(outcome.txid, shown.clientBalanceSat) + settingsStore.update { it.copy(lightningSetupStep = LN_SETUP_STEP_2) } + Logger.info("Funded Spark wallet with '${shown.clientBalanceSat}' sats in '${outcome.txid}'", context = TAG) + return true + } + private suspend fun getOrCreateSpendingOrder(): IBtOrder? { val state = _spendingUiState.value state.order?.let { return it } @@ -826,7 +887,12 @@ class TransferViewModel @Inject constructor( } } - private suspend fun onEstimateReady(clientBalanceSat: ULong, lspBalanceSat: ULong, feeSat: ULong) { + private suspend fun onEstimateReady( + clientBalanceSat: ULong, + lspBalanceSat: ULong, + feeSat: ULong, + isSpark: Boolean = false, + ) { settingsStore.update { it.copy(lightningSetupStep = 0) } if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return pendingHwFundingBroadcast = null @@ -839,6 +905,7 @@ class TransferViewModel @Inject constructor( feeSat = feeSat, order = null, isAdvanced = false, + isSpark = isSpark, isConfirmPaying = false, hasPendingHwBroadcast = false, hwMiningFeeSats = 0uL, @@ -857,6 +924,19 @@ class TransferViewModel @Inject constructor( _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget, hwFundingWalletId = null) } val availableAmount = fundingBudget ?: 0uL + if (isSparkFunding()) { + val maxSend = availableAmount.toLong() + _spendingUiState.update { + it.copy( + maxAllowedToSend = maxSend, + isLoading = false, + balanceAfterFee = maxSend, + quarterAmount = (maxSend.toDouble() * 0.25).roundToLong(), + ) + } + return@launch + } + val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { _spendingUiState.update { it.copy(isLoading = false) } @@ -1871,7 +1951,20 @@ class TransferViewModel @Inject constructor( if (_savingsSwapState.value.quote != null) SavingsTransferMode.SWAP else SavingsTransferMode.CLOSE /** Closes the channels selected earlier, pending closure */ - suspend fun closeSelectedChannels() = closeChannels(channelsToClose) + suspend fun closeSelectedChannels(): List { + withdrawSparkToSavings() + return closeChannels(channelsToClose) + } + + private suspend fun withdrawSparkToSavings() { + val balance = sparkRepo.state.value.balanceSats + if (balance == 0uL) return + val address = lightningRepo.newAddress().getOrElse { + Logger.error("Failed to get address for Spark withdrawal", it, context = TAG) + return + } + sparkRepo.withdrawAll(address).onFailure { ToastEventBus.send(it) } + } private suspend fun closeChannels(channels: List): List { lightningRepo.awaitPeerConnected() @@ -2061,6 +2154,10 @@ data class TransferToSpendingUiState( val feeSat: ULong = 0uL, val order: IBtOrder? = null, val isAdvanced: Boolean = false, + /** Funds the Spark wallet instead of buying a Blocktank channel. */ + val isSpark: Boolean = false, + /** Estimated fee Spark keeps from the deposit to claim it into Spending. */ + val sparkClaimFeeSats: ULong = 0uL, val maxAllowedToSend: Long = 0, val balanceAfterFee: Long = 0, val quarterAmount: Long = 0, diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 05ed1b131e..8f35c9b0be 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -42,6 +42,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 @@ -70,6 +71,7 @@ class WalletViewModel @Inject constructor( private val migrationService: MigrationService, private val connectivityRepo: ConnectivityRepo, private val boltzService: BoltzService, + sparkRepo: SparkRepo, ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" @@ -89,6 +91,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 ca78e334bc..a1c9abdbdb 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -225,6 +225,16 @@ في قائمة انتظار الفتح جارٍ فتح الاتصال يرجى الانتظار، جارٍ تحويل أموالك. يجب أن يستغرق هذا <accent>±10 دقائق.</accent> + Spark + رسوم المطالبة + %1$d/3 تأكيدات + إيداع + إيداعات تمت المطالبة بها + إيداعات قيد التنفيذ + المزوّد + Spark عبر Breez + بلا حد + يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة. قابل للإنفاق على السلسلة الإنفاق رسوم السيولة @@ -564,6 +574,7 @@ جهات الاتصال الملف الشخصي الإعدادات + بيانات الخروج من Spark العلامات سجل المعاملات التعزيزات والتحويلات 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 449f254bc3..22bba6b9a7 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -232,6 +232,16 @@ En Fila Para la Apertura Abriendo Conexión Por favor, espere, su transferencia está en curso. Esto debería tardar <accent>±10 minutos.</accent> + Spark + Comisión de reclamo + %1$d/3 confirmaciones + Depósito + 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 Comisión por liquidez @@ -586,6 +596,7 @@ Contactos Perfil Ajustes + Datos de salida de Spark Etiquetas Registro de transacciones Impulsos y transferencias diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index ebc566b16c..28c43d4208 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -229,6 +229,16 @@ En cua per obrir Obertura de connexió Si us plau, espera, la transferència de fons està en curs. Hauria de trigar <accent>±10 minuts.</accent> + Spark + Comissió de reclamació + %1$d/3 confirmacions + Dipòsit + 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 Tarifa de liquiditat @@ -579,6 +589,7 @@ Contactes Perfil Configuracions + Dades de sortida de Spark Etiquetes Registre de transaccions Impulsos i transferències diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f211f782bc..174882164b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -232,6 +232,16 @@ Ve frontě na otevření Otevírání spojení Počkejte prosím, probíhá převod finančních prostředků. To by mělo trvat <accent>±10 minut.</accent> + Spark + Poplatek za připsání + %1$d/3 potvrzení + Vklad + 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 Poplatek za likviditu @@ -584,6 +594,7 @@ Kontakty Profil Nastavení + Data pro výstup ze Spark Tagy Transakční protokol Posílení a převody diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 00d02eec7d..6cd4b61191 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -101,6 +101,16 @@ Abhängig von der Gebühr Abhängig von der Gebühr Abhängig von der Gebühr + Spark + Einlösegebühr + %1$d/3 Bestätigungen + Einzahlung + 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\n<accent>Balance</accent> Fülle dein Guthaben auf, um sofortige und günstige Transaktionen mit Freunden, Familie und Händlern zu genießen. Los geht\'s @@ -637,6 +647,7 @@ Transaktionshistorie Boosts & Übertragungen Einstellungen + Spark-Ausstiegsdaten Widgets Tags Profil diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index b8321c952e..32dec19f4d 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -225,6 +225,16 @@ Σε ουρά για άνοιγμα Άνοιγμα σύνδεσης Περίμενε, η μεταφορά κεφαλαίων σου είναι σε εξέλιξη. Αυτό θα διαρκέσει <accent>±10 λεπτά.</accent> + Spark + Χρέωση εξαργύρωσης + %1$d/3 επιβεβαιώσεις + Κατάθεση + Εξαργυρωμένες καταθέσεις + Καταθέσεις σε εξέλιξη + Πάροχος + Spark μέσω Breez + Χωρίς όριο + Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain. Διαθέσιμα onchain Δαπάνες Τέλος ρευστότητας @@ -565,6 +575,7 @@ Επαφές Προφίλ Ρυθμίσεις + Δεδομένα εξόδου Spark Ετικέτες Αρχείο συναλλαγών Ενισχύσεις & Μεταφορές diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 86ed051d4f..b0d60f6f12 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -231,6 +231,16 @@ En cola para apertura Abriendo Conexión Por favor espera, tu transferencia de fondos está en curso. Debería tardar <accent>±10 minutos.</accent> + Spark + Comisión de reclamación + %1$d/3 confirmaciones + Depósito + 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 Comisión de liquidez @@ -583,6 +593,7 @@ Contactos Perfil Ajustes + Datos de salida de Spark Etiquetas Registro de Transacciones Impulsos y transferencias diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4172a81fa7..a16f7a4b08 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -163,6 +163,16 @@ Pago Exitoso En Cola para Apertura Por favor espera, la transferencia de tus fondos está en progreso. Esto debería llevar <accent>±10 minutos.</accent> + Spark + Comisión de reclamo + %1$d/3 confirmaciones + Depósito + 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 Comisión de liquidez Capacidad de\n<accent>recepción</accent> @@ -610,6 +620,7 @@ Registro de Conexiones Registro de Transacciones Ajustes + Datos de salida de Spark Widgets Etiquetas Perfil diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b072d8be67..b3ccab472b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -100,6 +100,16 @@ Dépend des frais Dépend des frais Dépend des frais + Spark + Frais de réclamation + %1$d/3 confirmations + Dépôt + 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. Dépenses \n<accent>Solde</accent> Alimentez votre solde de dépenses pour profiter de transactions instantanées et bon marché avec vos amis, votre famille et les commerçants. Commencer @@ -624,6 +634,7 @@ Journal des transactions Boosts et transferts Réglages + Données de sortie Spark Widgets Tags Profil diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 21f7c48147..bc7c869d1e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -230,6 +230,16 @@ In Coda Per l\'Apertura Apertura della Connessione Attendi, il trasferimento dei fondi è in corso. L\'operazione dovrebbe richiedere <accent>±10 minuti.</accent> + Spark + Commissione di riscatto + %1$d/3 conferme + Deposito + 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 Commissione liquidita\' @@ -582,6 +592,7 @@ Contatti Profilo Impostazioni + Dati di uscita Spark Tag Log delle transazioni Boost e Trasferimenti diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 341a98ab46..06f9dab5d1 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -229,6 +229,16 @@ In wachtrij voor openen Verbinding openen Even geduld, je overboeking is bezig. Dit duurt <accent>±10 minuten.</accent> + Spark + Claimkosten + %1$d/3 bevestigingen + Storting + 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 Liquiditeitsvergoeding @@ -580,6 +590,7 @@ Contacten Profiel Instellingen + Spark-exitgegevens Tags Transactielog Boosts & overboekingen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 90c7f9c390..634530d48b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -102,6 +102,16 @@ Zależy od opłaty Zależy od opłaty Zależy od opłaty + Spark + Opłata za odbiór + %1$d/3 potwierdzeń + Wpłata + 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 \n<accent>Wydatków</accent> Zasil swoje saldo wydatków, aby cieszyć się natychmiastowymi i tanimi transakcjami z przyjaciółmi, rodziną i sprzedawcami. Rozpocznij @@ -623,6 +633,7 @@ Log transakcji Przyśpieszenia i transfery Ustawienia + Dane wyjścia ze Spark Widgety Tagi Profil diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 15d2f9aae1..497238db0c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -233,6 +233,16 @@ Na Fila Para Abertura Abrindo Conexão Aguarde, sua transferência de fundos está em andamento. Isso deve levar <accent>±10 minutos.</accent> + Spark + Taxa de resgate + %1$d/3 confirmações + Depósito + 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 Taxa de liquidez @@ -587,6 +597,7 @@ Contatos Perfil Ajustes + Dados de saída do Spark Tags Histórico de transações Boosts e transferências diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 70081cffc2..87ab9569c1 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -97,6 +97,16 @@ Depende da taxa Depende da taxa Depende da taxa + Spark + Taxa de resgate + %1$d/3 confirmações + Depósito + 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\n<accent>Gastos</accent> Use seu saldo de gastos para desfrutar de transações instantâneas e baratas com amigos, familiares e comerciantes. Começar @@ -600,6 +610,7 @@ Histórico de transações Boosts e transferências Ajustes + Dados de saída do Spark Widgets Tags Perfil diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9fcc5a4ff2..4fa4e41107 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -245,6 +245,16 @@ В Очереди на Открытие Открытие Соединения Пожалуйста, подождите, ваш перевод средств выполняется. Это займёт <accent>±10 минут.</accent> + Spark + Комиссия за зачисление + %1$d/3 подтверждений + Депозит + Зачисленные депозиты + Депозиты в обработке + Провайдер + Spark через Breez + Без ограничений + Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода. Доступно Onchain Расходы Комиссия за Ликвидность @@ -602,6 +612,7 @@ Контакты Профиль Настройки + Данные выхода из Spark Теги Журнал Транзакций Ускорения и Переводы diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b5dd29299..153c1b17fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -387,6 +387,16 @@ Queued For Opening Opening Connection Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> + Spark + Claim fee + %1$d/3 confirmations + Deposit + 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 the liquidity fee for this receiving capacity. Choose a smaller amount. @@ -901,6 +911,7 @@ Contacts Profile Settings + Spark exit data Tags Transaction Log Boosts & Transfers diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 5e192dbd06..ee4dc923ee 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -61,6 +61,7 @@ import to.bitkit.models.KnownDevice import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PaykitUsdtStateBackup +import to.bitkit.models.SparkExitBackupV1 import to.bitkit.models.TransportType import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WalletScope @@ -110,6 +111,7 @@ class BackupRepoTest : BaseUnitTest() { private val keychain = mock() private val onchainSendAttemptStore = mock() private val transferRepo = mock() + private val sparkRepo = mock() private val vssStoreIdProvider = mock() private val preActivityMetadataRepo = mock() private val lightningService = mock() @@ -185,9 +187,59 @@ class BackupRepoTest : BaseUnitTest() { privatePaykitAddressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) + 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() } + @Test + fun `spark exit backup uploads the exported state and restores it`() = 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() + + verify(sparkRepo).restoreExitBackup(backup, 0) + } + + @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( @@ -2102,6 +2154,7 @@ class BackupRepoTest : BaseUnitTest() { onchainSendAttemptStore = onchainSendAttemptStore, transferRepo = Provider { transferRepo }, 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 22d8c5b51a..668af1981a 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -82,6 +82,7 @@ import to.bitkit.services.LnurlService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.UrlValidator @@ -116,6 +117,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() @@ -158,6 +160,7 @@ class LightningRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + sparkRepo = sparkRepo, ) } @@ -222,6 +225,7 @@ class LightningRepoTest : BaseUnitTest() { transferRepo = mock(), activityRepo = mock(), hwWalletRepo = hardware, + sparkRepo = sparkRepo, ) } @@ -1948,6 +1952,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()) @@ -2373,6 +2378,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() @@ -2443,6 +2449,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() @@ -2524,6 +2531,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() @@ -3003,6 +3011,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/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt new file mode 100644 index 0000000000..85f9514af7 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt @@ -0,0 +1,84 @@ +package to.bitkit.repositories + +import breez_sdk_spark.GetInfoResponse +import breez_sdk_spark.ImportUnilateralExitStateResponse +import kotlinx.coroutines.flow.MutableSharedFlow +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.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.env.Env +import to.bitkit.models.SparkExitBackupV1 +import to.bitkit.services.SparkService +import to.bitkit.test.BaseUnitTest +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class SparkRepoTest : BaseUnitTest() { + private val sparkService = mock() + + private fun createSut() = SparkRepo( + bgDispatcher = testDispatcher, + sparkService = sparkService, + activityRepo = { mock() }, + settingsStore = mock(), + ) + + @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(MutableSharedFlow()) + 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 `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) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index e4506b8a6b..45924daf34 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -49,6 +49,7 @@ import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeIncomplete import to.bitkit.usecases.WipeWalletUseCase @@ -66,6 +67,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() @@ -173,6 +175,7 @@ class WalletRepoTest : BaseUnitTest() { transferRepo = transferRepo, activityRepo = activityRepo, hwWalletRepo = hwWalletRepo, + 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..07505a3610 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt @@ -0,0 +1,17 @@ +package to.bitkit.services + +import breez_sdk_spark.SendPaymentOptions +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) + } +} 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/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 203f6163ea..b9be1eac41 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -44,6 +44,7 @@ import to.bitkit.services.BoltzService import to.bitkit.services.MigrationService import to.bitkit.services.PendingChannelMigration import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.utils.AppError import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel @@ -67,6 +68,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()) @@ -104,6 +106,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) } @@ -536,6 +539,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -602,6 +606,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) testSut.start() @@ -657,6 +662,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) // Trigger restore to put state in non-idle @@ -696,6 +702,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) testSut.restoreWallet("mnemonic", null) @@ -786,6 +793,7 @@ class WalletViewModelTest : BaseUnitTest() { migrationService = migrationService, connectivityRepo = connectivityRepo, boltzService = boltzService, + sparkRepo = sparkRepo, ) advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt index 9bacae9389..7bb264c466 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 @@ -49,6 +49,26 @@ 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, + isGeoBlocked = true, + ) + + assertEquals( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + verify(walletRepo, times(0)).inboundLiquiditySats() + } + @Test fun `onClickContinue should emit choose amount for spending below CJIT minimum`() = test { whenever(blocktankRepo.maxCjitAmountSats()).thenReturn(Result.success(100_000u)) diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index b663a8bdf9..02767d96bd 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,6 +64,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { transferRepo = transferRepo, settingsStore = settingsStore, hwWalletRepo = hwWalletRepo, + sparkRepo = sparkRepo, ) } @@ -97,6 +102,21 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { assertEquals(200_000uL, balanceState.totalSats) } + @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) + assertEquals(30_000uL, 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/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index f915475380..b9d3968ee0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -99,6 +99,7 @@ import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.idleSparkRepo import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.screens.transfer.previewSpendingState import to.bitkit.ui.shared.toast.ToastEventBus @@ -120,6 +121,7 @@ class TransferViewModelTest : BaseUnitTest() { private lateinit var sut: TransferViewModel private val context = mock() + private val sparkRepo = idleSparkRepo() private val lightningRepo = mock() private val blocktankRepo = mock() private val hwWalletRepo = mock() @@ -204,6 +206,7 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + sparkRepo = sparkRepo, boltzService = boltzService, currencyRepo = currencyRepo, ) @@ -2864,6 +2867,7 @@ class TransferViewModelTest : BaseUnitTest() { cacheStore = cacheStore, transferRepo = transferRepo, clock = clock, + sparkRepo = sparkRepo, boltzService = boltzService, currencyRepo = currencyRepo, ) 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 bfde34c946..496eec1985 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -322,6 +322,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 1b857067ed..3af9a3faa9 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-rc10" } +breez-sdk-spark = { module = "breez_sdk_spark:bindings-android", version = "0.27.2" } paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc1" } 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/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 {