Repository navigation
feat: move LN channel balances to spark #1774
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ovitrif
wants to merge
21
commits into
feat/breez-spark-lightning
Choose a base branch
from
feat/move-to-spark
base: feat/breez-spark-lightning
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,457
−2
Open
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 b414f5f
test: keep the move to spark journey platform neutral
ovitrif e289d5a
fix: list move to spark after the channels in connections
ovitrif dbff2f7
fix: fit move to spark labels and headlines to small screens
ovitrif 74dbe6a
fix: keep the move to spark sheet copy whole on small screens
ovitrif 967292c
fix: build move to spark without the spark exit
ovitrif fe4488a
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif 864be2f
chore: drop the fee from the move changelog line
ovitrif cae73d2
refactor: gate move to spark on one spark check
ovitrif 09219e4
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif 84155d9
fix: address move to spark review findings
ovitrif be73d82
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif d8b7d32
chore: format move to spark repo
ovitrif f715f9d
fix: match move failures by payment id and route error
ovitrif 591d3c0
fix: skip move to spark sheet in e2e builds
ovitrif a593b90
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif b295a6e
Revert "fix: skip move to spark sheet in e2e builds"
ovitrif 098510c
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif 977cfd0
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif 95fe192
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif c1f0b53
Merge remote-tracking branch 'origin/feat/breez-spark-lightning' into…
ovitrif File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
241 changes: 241 additions & 0 deletions
241
app/src/main/java/to/bitkit/repositories/MoveToSparkRepo.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| }.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'") | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.