Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1b861be
feat: channel balances move to spark at low fees
ovitrif Nov 4, 2026
b414f5f
test: keep the move to spark journey platform neutral
ovitrif Nov 4, 2026
e289d5a
fix: list move to spark after the channels in connections
ovitrif Nov 4, 2026
dbff2f7
fix: fit move to spark labels and headlines to small screens
ovitrif Nov 4, 2026
74dbe6a
fix: keep the move to spark sheet copy whole on small screens
ovitrif Nov 4, 2026
967292c
fix: build move to spark without the spark exit
ovitrif Nov 5, 2026
fe4488a
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 5, 2026
864be2f
chore: drop the fee from the move changelog line
ovitrif Nov 5, 2026
cae73d2
refactor: gate move to spark on one spark check
ovitrif Nov 5, 2026
09219e4
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 5, 2026
84155d9
fix: address move to spark review findings
ovitrif Nov 6, 2026
be73d82
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 6, 2026
d8b7d32
chore: format move to spark repo
ovitrif Nov 6, 2026
f715f9d
fix: match move failures by payment id and route error
ovitrif Nov 6, 2026
591d3c0
fix: skip move to spark sheet in e2e builds
ovitrif Nov 6, 2026
a593b90
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 6, 2026
b295a6e
Revert "fix: skip move to spark sheet in e2e builds"
ovitrif Nov 6, 2026
098510c
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 6, 2026
977cfd0
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 6, 2026
95fe192
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 6, 2026
c1f0b53
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ data class SettingsData(
val publicPaykitUsdtEnabled: Boolean = true,
val usdtPreference: UsdtPreference = UsdtPreference.UNDECIDED,
val hasSeenUsdtIntro: Boolean = false,
val hasSeenMoveToSparkIntro: Boolean = false,
val publicPaykitBolt11: String = "",
val publicPaykitBolt11PaymentHash: String = "",
val publicPaykitBolt11ExpiresAtMillis: Long = 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import to.bitkit.models.NewTransactionSheetType
import to.bitkit.models.msatCeilOf
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.BackupRepo
import to.bitkit.repositories.MoveToSparkRepo
import to.bitkit.repositories.SparkRepo
import to.bitkit.services.MigrationService
import to.bitkit.utils.Logger
Expand All @@ -37,6 +38,7 @@ class NotifyPaymentReceivedHandler @Inject constructor(
private val clock: Clock,
private val receivedNotificationContent: ReceivedNotificationContent,
private val sparkRepo: SparkRepo,
private val moveToSparkRepo: MoveToSparkRepo,
) {
private val presentationClaimsLock = Any()
private val presentationClaims = mutableSetOf<String>()
Expand Down Expand Up @@ -109,6 +111,8 @@ class NotifyPaymentReceivedHandler @Inject constructor(

private suspend fun shouldShowLightning(command: NotifyPaymentReceived.Command.Lightning): Boolean {
val paymentId = command.event.paymentId ?: return false
// A move to Spark reports its outcome on its own screen.
if (moveToSparkRepo.isMovePayment(command.event.paymentHash)) return false
delay(DELAY_FOR_ACTIVITY_SYNC_MS)
return !activityRepo.isActivitySeen(paymentId)
}
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1499,6 +1499,19 @@ class LightningRepo @Inject constructor(
return payInvoiceWithLdk(bolt11, sats, paymentDeadlineAt, maxTotalRoutingFeeMsat, onBeforeSend)
}

/** Pays [bolt11] from the channels only, never falling back to Spark, which moving a channel balance needs. */
suspend fun payInvoiceOverLdk(
bolt11: String,
sats: ULong?,
maxTotalRoutingFeeMsat: ULong?,
): Result<PaymentId> = payInvoiceWithLdk(bolt11, sats, null, maxTotalRoutingFeeMsat, onBeforeSend = { true })

/** The routing fee for paying [amountSats] to [bolt11] from the channels, never quoted on Spark. */
suspend fun estimateLdkRoutingFeesForAmount(bolt11: String, amountSats: ULong): Result<ULong> =
executeWhenNodeRunning("estimateLdkRoutingFeesForAmount") {
lightningService.estimateRoutingFeesForAmount(bolt11, amountSats)
}

private suspend fun payInvoiceWithLdk(
bolt11: String,
sats: ULong?,
Expand Down
241 changes: 241 additions & 0 deletions app/src/main/java/to/bitkit/repositories/MoveToSparkRepo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package to.bitkit.repositories

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.lightningdevkit.ldknode.Bolt11Invoice
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.Event
import org.lightningdevkit.ldknode.NodeException
import org.lightningdevkit.ldknode.PaymentFailureReason
import to.bitkit.di.BgDispatcher
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
import to.bitkit.models.safe
import to.bitkit.utils.AppError
import to.bitkit.utils.LdkError
import to.bitkit.utils.Logger
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.minutes

/**
* Moves the balance of the user's Lightning channels to their own Spark wallet, paying a Spark invoice over the
* channels only. Blocktank is closing its channels, and this is the cheapest way to keep the balance in Spending.
*/
@Singleton
class MoveToSparkRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val lightningRepo: LightningRepo,
private val sparkRepo: SparkRepo,
) {
companion object {
private const val TAG = "MoveToSparkRepo"
private const val INVOICE_DESCRIPTION = "Move to Spark"
private const val INVOICE_EXPIRY_SECS = 3600u
private const val MAX_QUOTE_ROUNDS = 4

/** How long the move waits for the payment to settle before it reports it as still in flight. */
private val PAYMENT_TIMEOUT = 2.minutes

/** The failures that mean no route carried the amount, so a smaller one may route. */
private val NO_ROUTE_REASONS =
setOf(PaymentFailureReason.ROUTE_NOT_FOUND, PaymentFailureReason.RETRIES_EXHAUSTED)
}

private val ownPaymentHashes = ConcurrentHashMap.newKeySet<String>()
private val watchedPaymentHashes = ConcurrentHashMap.newKeySet<String>()
private val reportedPaymentHashes = ConcurrentHashMap.newKeySet<String>()

/** Whether [paymentHash] is a move to Spark, whose send and receive the app does not announce as payments. */
fun isMovePayment(paymentHash: String): Boolean = paymentHash in ownPaymentHashes

/**
* Whether the move screen waits on [paymentHash] or already reported its outcome, so the app raises no failure
* notice of its own; after a timeout or cancellation the app does.
*/
fun isWatchedMovePayment(paymentHash: String): Boolean =
paymentHash in watchedPaymentHashes || paymentHash in reportedPaymentHashes

/** Whether the channels hold a balance worth moving to a usable Spark wallet. */
val canMove: Flow<Boolean> = combine(lightningRepo.lightningState, sparkRepo.state) { lightning, spark ->
MoveToSparkMath.canMove(lightning.channels, spark)
}.distinctUntilChanged()

/** The most the channels can send now, before routing fees. */
fun movableSats(): ULong = MoveToSparkMath.movableSats(lightningRepo.lightningState.value.channels)

/**
* Quotes moving [targetSats], or everything the channels can send, to a new Spark invoice.
* The amount leaves room for the routing fee, which the quote carries and the move caps the payment at.
*/
suspend fun quote(targetSats: ULong? = null): Result<MoveToSparkQuote> = withContext(bgDispatcher) {
runSuspendCatching {
val limitSats = movableSats()
val wantedSats = targetSats?.coerceAtMost(limitSats) ?: limitSats
if (wantedSats < MoveToSparkMath.MIN_MOVE_SATS) throw MoveToSparkError.NothingToMove()
if (!MoveToSparkMath.isSparkUsable(sparkRepo.state.value)) throw MoveToSparkError.SparkUnavailable()

val invoice = sparkRepo.createInvoice(null, INVOICE_DESCRIPTION, INVOICE_EXPIRY_SECS).getOrThrow()
var amountSats = MoveToSparkMath.firstCandidate(wantedSats, limitSats)
var fitting: MoveToSparkQuote? = null
// The first amount leaves room for a guessed fee; once the real fee is known, the amount grows to fit it.
repeat(MAX_QUOTE_ROUNDS) {
if (amountSats < MoveToSparkMath.MIN_MOVE_SATS) return@repeat
val estimateSats = lightningRepo.estimateLdkRoutingFeesForAmount(invoice, amountSats).getOrElse {
fitting?.let { quote -> return@runSuspendCatching quote }
throw if (it.isNoRoute()) MoveToSparkError.TooLarge(amountSats, it) else it
}
val feeSats = MoveToSparkMath.quotedFeeSats(estimateSats)
val nextSats = minOf(wantedSats, MoveToSparkMath.amountAfterFee(limitSats, estimateSats))
if (amountSats.safe() + feeSats.safe() <= limitSats) {
fitting = MoveToSparkQuote(invoice, amountSats, feeSats)
if (nextSats <= amountSats) return@runSuspendCatching checkNotNull(fitting)
}
amountSats = nextSats
}
fitting ?: throw MoveToSparkError.TooLarge(amountSats)
}.onFailure {
Logger.warn("Failed to quote move to Spark of '$targetSats'", it, context = TAG)
}
}

/** Pays [quote] over the channels and waits for it to settle in Spark. */
suspend fun move(quote: MoveToSparkQuote): Result<MoveToSparkOutcome> = withContext(bgDispatcher) {
runSuspendCatching {
val paymentHash = Bolt11Invoice.fromStr(quote.invoice).paymentHash()
ownPaymentHashes += paymentHash
watchedPaymentHashes += paymentHash
try {
payAndWait(quote, paymentHash)
} finally {
watchedPaymentHashes -= paymentHash
Comment thread
ovitrif marked this conversation as resolved.
Resolved
}
}.onFailure {
Logger.error("Failed to move '${quote.amountSats}' sats to Spark", it, context = TAG)
}
}

private suspend fun payAndWait(quote: MoveToSparkQuote, paymentHash: String): MoveToSparkOutcome =
coroutineScope {
// LDK can report a failure without its hash, so the payment's id identifies it too once it is known.
val paymentId = AtomicReference<String?>(null)
val settled = async(start = CoroutineStart.UNDISPATCHED) {
lightningRepo.nodeEvents.first { it.settles(paymentHash, paymentId.get()) }
}
lightningRepo.payInvoiceOverLdk(
bolt11 = quote.invoice,
sats = quote.amountSats,
maxTotalRoutingFeeMsat = MoveToSparkMath.feeCapMsat(quote.feeSats),
).onSuccess { paymentId.set(it) }.getOrElse {
settled.cancel()
throw if (it.isNoRoute()) MoveToSparkError.TooLarge(quote.amountSats, it) else it
}
val event = withTimeoutOrNull(PAYMENT_TIMEOUT) { settled.await() }
if (event != null) reportedPaymentHashes += paymentHash
when (event) {
is Event.PaymentSuccessful -> MoveToSparkOutcome.Moved(
amountSats = quote.amountSats,
feeSats = (event.feePaidMsat ?: 0uL) / 1000uL,
)
is Event.PaymentFailed if event.reason in NO_ROUTE_REASONS ->
throw MoveToSparkError.TooLarge(quote.amountSats, reason = event.reason)
is Event.PaymentFailed -> throw MoveToSparkError.PaymentFailed(event.reason)
else -> {
settled.cancel()
MoveToSparkOutcome.InFlight(quote.amountSats)
}
}
}

private fun Throwable.isNoRoute(): Boolean = this is NodeException.RouteNotFound ||
(this as? LdkError)?.compactType == NodeException.RouteNotFound::class.simpleName

private fun Event.settles(paymentHash: String, paymentId: String?): Boolean = when (this) {
is Event.PaymentSuccessful -> this.paymentHash == paymentHash
is Event.PaymentFailed -> this.paymentHash == paymentHash || (paymentId != null && this.paymentId == paymentId)
else -> false
}
}

/** The amounts behind moving the channels' balance, kept apart so they can be tested without a node. */
object MoveToSparkMath {
/** Below this a move is not offered: what is left is about the routing fee. */
const val MIN_MOVE_SATS = 1_000uL

/** The fee guessed for the first quote of a full move, as a share of the amount in parts per million. */
private const val FIRST_FEE_GUESS_PPM = 5_000uL

/** The least fee guessed for the first quote of a full move. */
private const val MIN_FEE_GUESS_SATS = 10uL

/** Room left above the quoted fee, in sats, for the fee moving between the quote and the payment. */
private const val FEE_MARGIN_SATS = 2uL

fun movableSats(channels: List<ChannelDetails>?): ULong = channels.totalNextOutboundHtlcLimitSats()

/** Whether Spark can take a move; every Move to Spark check goes through here. */
fun isSparkUsable(spark: SparkState): Boolean = spark.isReady

/** Whether the channels hold a balance worth moving to a usable Spark wallet. */
fun canMove(channels: List<ChannelDetails>?, spark: SparkState): Boolean =
isSparkUsable(spark) && movableSats(channels) >= MIN_MOVE_SATS

/** The first amount to quote: [wantedSats] when it leaves room for fees, else the limit less a guessed fee. */
fun firstCandidate(wantedSats: ULong, limitSats: ULong): ULong {
val guessSats = maxOf(limitSats.safe() * FIRST_FEE_GUESS_PPM.safe() / 1_000_000uL, MIN_FEE_GUESS_SATS)
return minOf(wantedSats, amountAfterFee(limitSats, guessSats))
}

/** The amount that fits under [limitSats] together with the fee quoted for [estimateSats]. */
fun amountAfterFee(limitSats: ULong, estimateSats: ULong): ULong =
limitSats.safe() - quotedFeeSats(estimateSats).safe()

/** The routing fee to show for an estimate: the estimate and its margin, which also covers its rounding down. */
fun quotedFeeSats(estimateSats: ULong): ULong = estimateSats.safe() + FEE_MARGIN_SATS.safe()

/** The routing fee cap for the payment: exactly the quoted fee, so it never pays more than shown. */
fun feeCapMsat(feeSats: ULong): ULong = feeSats.safe() * 1000uL.safe()

/** The smaller amount to offer after one did not route: half of it, or nothing below the minimum. */
fun smallerAmount(sats: ULong): ULong? = (sats / 2uL).takeIf { it >= MIN_MOVE_SATS }
}

data class MoveToSparkQuote(
val invoice: String,
val amountSats: ULong,
val feeSats: ULong,
)

sealed interface MoveToSparkOutcome {
val amountSats: ULong

data class Moved(override val amountSats: ULong, val feeSats: ULong) : MoveToSparkOutcome

/** The payment left but had not settled when the move stopped waiting; Spark shows it once it does. */
data class InFlight(override val amountSats: ULong) : MoveToSparkOutcome
}

sealed class MoveToSparkError(message: String, cause: Throwable? = null) : AppError(message, cause) {
class NothingToMove : MoveToSparkError("The channels have nothing to move")
class SparkUnavailable : MoveToSparkError("Spark is not ready to receive")

/** [amountSats] did not route from the channels to Spark in one payment. */
class TooLarge(
val amountSats: ULong,
cause: Throwable? = null,
val reason: PaymentFailureReason? = null,
) : MoveToSparkError("Could not route '$amountSats' sats to Spark, reason '$reason'", cause)

/** The payment failed for a reason other than its route, such as an expired invoice. */
class PaymentFailed(val reason: PaymentFailureReason?) : MoveToSparkError("Failed to pay Spark, reason '$reason'")
}
28 changes: 28 additions & 0 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import to.bitkit.ext.walletId
import to.bitkit.models.NodeLifecycleState
import to.bitkit.models.Toast
import to.bitkit.repositories.ConnectivityState
import to.bitkit.repositories.MoveToSparkMath
import to.bitkit.repositories.PaykitPaymentRequestId
import to.bitkit.repositories.PaykitSubscriptionId
import to.bitkit.ui.Routes.ExternalConnection
Expand Down Expand Up @@ -204,6 +205,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.MoveToSparkScreen
import to.bitkit.ui.settings.lightning.SparkDetailScreen
import to.bitkit.ui.settings.pin.PinManagementScreen
import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen
Expand All @@ -223,6 +225,7 @@ import to.bitkit.ui.sheets.DisablePinSheet
import to.bitkit.ui.sheets.ForceTransferSheet
import to.bitkit.ui.sheets.HighBalanceWarningSheet
import to.bitkit.ui.sheets.LnurlAuthSheet
import to.bitkit.ui.sheets.MoveToSparkSheet
import to.bitkit.ui.sheets.PinSheet
import to.bitkit.ui.sheets.QrScanningSheet
import to.bitkit.ui.sheets.QuickPayIntroSheet
Expand Down Expand Up @@ -764,6 +767,14 @@ fun ContentView(
)
}

TimedSheetType.MOVE_TO_SPARK -> MoveToSparkSheet(
onLater = { appViewModel.dismissTimedSheet() },
onMove = {
appViewModel.dismissTimedSheet()
navController.navigateTo(Routes.MoveToSpark)
},
)

TimedSheetType.HIGH_BALANCE -> {
HighBalanceWarningSheet(
understoodClick = { appViewModel.dismissTimedSheet() },
Expand Down Expand Up @@ -1367,6 +1378,8 @@ private fun NavGraphBuilder.home(
SpendingWalletScreen(
channels = lightningState.channels,
hasSparkBalance = sparkState.balanceSats > 0uL,
canMoveToSpark = MoveToSparkMath.canMove(lightningState.channels, sparkState),
onMoveToSparkClick = { navController.navigateTo(Routes.MoveToSpark) },
lightningActivities = lightningActivities ?: persistentListOf(),
onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) },
onActivityItemClick = { navController.navToActivityDetail(it) },
Expand Down Expand Up @@ -2009,6 +2022,18 @@ private fun NavGraphBuilder.lightningConnections(
},
)
}
composableWithDefaultTransitions<Routes.MoveToSpark> {
MoveToSparkScreen(
viewModel = hiltViewModel(),
onBack = { navController.popBackStack() },
onCloseChannel = { channelId ->
// Closing returns to Lightning Connections, so the close screen opens on top of it.
navController.popBackStack()
navController.navigateTo(Routes.LightningConnections)
channelId?.let { navController.navigateTo(Routes.CloseConnection(it)) }
},
)
}
deepLinkableComposable<Routes.CloseConnection> {
val route = it.toRoute<Routes.CloseConnection>()
CloseConnectionScreen(
Expand Down Expand Up @@ -2480,6 +2505,9 @@ sealed interface Routes {
@Serializable
data class CloseConnection(val channelId: String) : Routes.DeepLinkable

@Serializable
data object MoveToSpark : Routes.InternalOnly

@Serializable
data object DevSettings : Routes.DeepLinkable

Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/ui/components/SheetHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ sealed interface Sheet {

/**@param priority Priority levels for timed sheets (higher number = higher priority)*/
enum class TimedSheetType(val priority: Int) {
MOVE_TO_SPARK(priority = 7),
APP_UPDATE(priority = 6),
USDT(priority = 5),
BACKUP(priority = 4),
Expand Down
Loading
Loading