Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -1472,6 +1472,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
198 changes: 198 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,198 @@
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.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.Logger
import java.util.concurrent.ConcurrentHashMap
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
}

private val ownPaymentHashes = 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 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 (!sparkRepo.state.value.isReady) 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 feeSats = lightningRepo.estimateLdkRoutingFeesForAmount(invoice, amountSats).getOrElse {
fitting?.let { quote -> return@runSuspendCatching quote }
throw MoveToSparkError.TooLarge(amountSats, it)
}
val nextSats = minOf(wantedSats, MoveToSparkMath.amountAfterFee(limitSats, feeSats))
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
coroutineScope {
val settled = async(start = CoroutineStart.UNDISPATCHED) {
lightningRepo.nodeEvents.first { it.settles(paymentHash) }
}
lightningRepo.payInvoiceOverLdk(
bolt11 = quote.invoice,
sats = quote.amountSats,
maxTotalRoutingFeeMsat = MoveToSparkMath.feeCapMsat(quote.feeSats),
).getOrElse {
settled.cancel()
throw MoveToSparkError.TooLarge(quote.amountSats, it)
}
when (val event = withTimeoutOrNull(PAYMENT_TIMEOUT) { settled.await() }) {
is Event.PaymentSuccessful -> MoveToSparkOutcome.Moved(
amountSats = quote.amountSats,
feeSats = (event.feePaidMsat ?: 0uL) / 1000uL,
)
is Event.PaymentFailed -> throw MoveToSparkError.TooLarge(quote.amountSats, reason = event.reason)
else -> {
settled.cancel()
MoveToSparkOutcome.InFlight(quote.amountSats)
}
}
}
}.onFailure {
Logger.error("Failed to move '${quote.amountSats}' sats to Spark", it, context = TAG)
}
}

private fun Event.settles(paymentHash: String): Boolean = when (this) {
is Event.PaymentSuccessful -> this.paymentHash == paymentHash
is Event.PaymentFailed -> this.paymentHash == paymentHash
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 the channels hold a balance worth moving to a usable Spark wallet. */
fun canMove(channels: List<ChannelDetails>?, spark: SparkState): Boolean =
spark.isReady && 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 [feeSats] and its margin. */
fun amountAfterFee(limitSats: ULong, feeSats: ULong): ULong =
limitSats.safe() - (feeSats.safe() + FEE_MARGIN_SATS.safe()).safe()

/** The routing fee cap for the payment: the quoted fee and its margin, so it never pays more than shown. */
fun feeCapMsat(feeSats: ULong): ULong = (feeSats.safe() + FEE_MARGIN_SATS.safe()).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)
}
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 @@ -1366,6 +1377,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 @@ -2003,6 +2016,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 @@ -2474,6 +2499,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
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import to.bitkit.ui.LocalBalances
import to.bitkit.ui.components.BalanceHeaderView
import to.bitkit.ui.components.EmptyStateView
import to.bitkit.ui.components.IncomingTransfer
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.SecondaryButton
import to.bitkit.ui.components.StatusBarSpacer
import to.bitkit.ui.components.TabBar
Expand Down Expand Up @@ -70,6 +71,8 @@ fun SpendingWalletScreen(
onBackClick: () -> Unit,
balances: BalanceState = LocalBalances.current,
hasSparkBalance: Boolean = false,
canMoveToSpark: Boolean = false,
onMoveToSparkClick: () -> Unit = {},
) {
val showEmptyState by remember(balances.totalLightningSats, lightningActivities.size) {
val hasLnFunds = balances.totalLightningSats == 0uL
Expand Down Expand Up @@ -176,6 +179,24 @@ fun SpendingWalletScreen(
if (!showEmptyState) {
item { VerticalSpacer(32.dp) }

if (canMoveToSpark) {
item {
PrimaryButton(
onClick = onMoveToSparkClick,
text = stringResource(R.string.lightning__move_to_spark__nav_title),
icon = {
Icon(
painter = painterResource(R.drawable.ic_lightning),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
},
modifier = Modifier.testTag("MoveToSpark")
)
}
item { VerticalSpacer(16.dp) }
}

if (canTransfer) {
item {
SecondaryButton(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ fun LightningConnectionsScreen(
navController.navigateTo(Routes.ChannelDetail(channelUi.details.channelId))
},
onClickSpark = { navController.navigateTo(Routes.SparkDetail) },
onClickMoveToSpark = { navController.navigateTo(Routes.MoveToSpark) },
onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) },
onRefresh = {
viewModel.onPullToRefresh()
Expand All @@ -124,6 +125,7 @@ private fun Content(
onClickExportLogs: () -> Unit = {},
onClickChannel: (ChannelUi) -> Unit = {},
onClickSpark: () -> Unit = {},
onClickMoveToSpark: () -> Unit = {},
onOpenTx: (String) -> Unit = {},
onRefresh: () -> Unit = {},
) {
Expand Down Expand Up @@ -237,6 +239,15 @@ private fun Content(
)
}

if (uiState.canMoveToSpark) {
VerticalSpacer(16.dp)
PrimaryButton(
text = stringResource(R.string.lightning__move_to_spark__nav_title),
onClick = onClickMoveToSpark,
modifier = Modifier.testTag("MoveToSpark")
)
}

// Closed Channels Section
AnimatedVisibility(visible = showClosed && uiState.closedChannels.isNotEmpty()) {
Column {
Expand Down
Loading
Loading