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
6 changes: 6 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ struct MainNavView: View {
) {
config in SecuritySheet(config: config)
}
.sheet(item: $sheets.moveToSparkSheetItem, onDismiss: {
sheets.hideSheetIfActive(.moveToSpark, reason: "Move to Spark intro dismissed")
}) { config in
MoveToSparkSheet(config: config)
}
.sheet(item: $sheets.usdtIntroSheetItem, onDismiss: {
sheets.hideSheetIfActive(.usdtIntro, reason: "USDT intro dismissed")
}) { config in
Expand Down Expand Up @@ -705,6 +710,7 @@ struct MainNavView: View {
case .connections: LightningConnectionsView()
case let .connectionDetail(channelId): LightningConnectionDetailView(channelId: channelId)
case .sparkConnectionDetail: SparkConnectionDetailView()
case .moveToSpark: MoveToSparkView()
case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel)
case .node: NodeStateView()
case .electrumSettings: ElectrumSettingsScreen()
Expand Down
210 changes: 210 additions & 0 deletions Bitkit/Managers/MoveToSparkManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import Foundation
import LDKNode

/// The amounts behind moving the channels' balance to Spark, kept apart so they can be tested without a node.
enum MoveToSparkMath {
/// Below this a move is not offered: what is left is about the routing fee.
static let minMoveSats: UInt64 = 1000
/// The fee guessed for the first quote of a full move, as a share of the amount in parts per million.
private static let firstFeeGuessPpm: UInt64 = 5000
/// The least fee guessed for the first quote of a full move.
private static let minFeeGuessSats: UInt64 = 10
/// Room left above the quoted fee, in sats, for the fee moving between the quote and the payment.
private static let feeMarginSats: UInt64 = 2

static func movableSats(channels: [ChannelDetails]?) -> UInt64 {
(channels ?? []).filter(\.isUsable).map { $0.nextOutboundHtlcLimitMsat / 1000 }.reduce(0, +)
}

/// Whether Spark can take a move; every Move to Spark check goes through here.
@MainActor
static func isSparkUsable(_ spark: SparkManager) -> Bool {
spark.isReady
}

/// Whether the channels hold a balance worth moving to a usable Spark wallet.
static func canMove(channels: [ChannelDetails]?, isSparkUsable: Bool) -> Bool {
isSparkUsable && movableSats(channels: channels) >= minMoveSats
}

/// The first amount to quote: `wantedSats` when it leaves room for fees, else the limit less a guessed fee.
static func firstCandidate(wantedSats: UInt64, limitSats: UInt64) -> UInt64 {
let guessSats = max(limitSats.multipliedReportingOverflow(by: firstFeeGuessPpm).partialValue / 1_000_000, minFeeGuessSats)
return min(wantedSats, amountAfterFee(limitSats: limitSats, feeSats: guessSats))
}

/// The amount that fits under `limitSats` together with `feeSats` and its margin.
static func amountAfterFee(limitSats: UInt64, feeSats: UInt64) -> UInt64 {
let reserved = feeSats.addingReportingOverflow(feeMarginSats)
return reserved.overflow || reserved.partialValue >= limitSats ? 0 : limitSats - reserved.partialValue
}

/// The routing fee cap for the payment: the quoted fee and its margin, so it never pays more than shown.
static func feeCapMsat(feeSats: UInt64) -> UInt64 {
(feeSats + feeMarginSats) * 1000
}

/// The smaller amount to offer after one did not route: half of it, or nothing below the minimum.
static func smallerAmount(_ sats: UInt64) -> UInt64? {
let half = sats / 2
return half >= minMoveSats ? half : nil
}
}

struct MoveToSparkQuote: Equatable {
let invoice: String
let amountSats: UInt64
let feeSats: UInt64
}

enum MoveToSparkError: Error {
case nothingToMove
case sparkUnavailable
/// `amountSats` did not route from the channels to Spark in one payment.
case tooLarge(amountSats: UInt64)
}

/// The payments that move a channel balance to Spark, whose send and receive the app does not announce as payments.
@MainActor
enum MoveToSparkPayments {
private static var paymentHashes = Set<String>()

static func insert(_ paymentHash: String) {
paymentHashes.insert(paymentHash)
}

static func contains(_ paymentHash: String?) -> Bool {
paymentHash.map(paymentHashes.contains) ?? false
}
}

enum MoveToSparkFailure {
case nothingToMove
case sparkUnavailable
case unknown
}

enum MoveToSparkState: Equatable {
case quoting
case confirm(MoveToSparkQuote, isMoving: Bool)
/// `hasRest`: the channels can still send enough to move again, as after a smaller amount.
case done(amountSats: UInt64, feeSats: UInt64, closeChannel: ChannelDetails?, hasRest: Bool)
/// `amountSats` did not route in one payment; `smallerSats` is the next amount to try, if any is left.
case tooLarge(amountSats: UInt64, smallerSats: UInt64?)
case failed(MoveToSparkFailure)

static func == (lhs: MoveToSparkState, rhs: MoveToSparkState) -> Bool {
switch (lhs, rhs) {
case (.quoting, .quoting): true
case let (.confirm(a, am), .confirm(b, bm)): a == b && am == bm
case let (.done(a, af, ac, ar), .done(b, bf, bc, br)): a == b && af == bf && ac?.channelId == bc?.channelId && ar == br
case let (.tooLarge(a, asm), .tooLarge(b, bsm)): a == b && asm == bsm
case let (.failed(a), .failed(b)): a == b
default: false
}
}
}

/// Moves the channels' balance to the user's 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.
@Observable @MainActor
final class MoveToSparkManager {
private static let invoiceDescription = "Move to Spark"
private static let invoiceExpirySecs: UInt32 = 3600
private static let maxQuoteRounds = 4

private(set) var state: MoveToSparkState = .quoting

private let lightningService: LightningService
private let spark: SparkManager

init() {
lightningService = .shared
spark = .shared
}

/// Quotes moving `targetSats`, or everything the channels can send.
func quote(wallet: WalletViewModel, targetSats: UInt64? = nil) async {
state = .quoting
do {
state = try await .confirm(makeQuote(channels: wallet.channels, targetSats: targetSats), isMoving: false)
} catch {
Logger.warn("Failed to quote move to Spark of '\(String(describing: targetSats))': \(error)", context: "MoveToSpark")
state = Self.state(for: error)
}
}

func move(wallet: WalletViewModel) async {
guard case let .confirm(quote, isMoving) = state, !isMoving else { return }
state = .confirm(quote, isMoving: true)
do {
let paymentHash = try Bolt11Invoice.fromStr(invoiceStr: quote.invoice).paymentHash()
MoveToSparkPayments.insert(String(describing: paymentHash))
let settled = try await wallet.sendOverChannels(
bolt11: quote.invoice,
sats: quote.amountSats,
maxFeeMsat: MoveToSparkMath.feeCapMsat(feeSats: quote.feeSats)
)
wallet.syncState()
let openChannels = (wallet.channels ?? []).filter(\.isChannelReady)
state = .done(
amountSats: quote.amountSats,
feeSats: settled.feePaidSats,
closeChannel: openChannels.count == 1 ? openChannels.first : nil,
hasRest: MoveToSparkMath.movableSats(channels: wallet.channels) >= MoveToSparkMath.minMoveSats
)
} catch {
Logger.error("Failed to move '\(quote.amountSats)' sats to Spark: \(error)", context: "MoveToSpark")
state = .tooLarge(amountSats: quote.amountSats, smallerSats: MoveToSparkMath.smallerAmount(quote.amountSats))
}
}

func makeQuote(channels: [ChannelDetails]?, targetSats: UInt64?) async throws -> MoveToSparkQuote {
let limitSats = MoveToSparkMath.movableSats(channels: channels)
let wantedSats = min(targetSats ?? limitSats, limitSats)
guard wantedSats >= MoveToSparkMath.minMoveSats else { throw MoveToSparkError.nothingToMove }
guard MoveToSparkMath.isSparkUsable(spark) else { throw MoveToSparkError.sparkUnavailable }

let invoice = try await spark.receive(
amountSats: nil,
description: Self.invoiceDescription,
expirySecs: Self.invoiceExpirySecs
)
var amountSats = MoveToSparkMath.firstCandidate(wantedSats: wantedSats, limitSats: limitSats)
var fitting: MoveToSparkQuote?
// The first amount leaves room for a guessed fee; once the real fee is known, the amount grows to fit it.
for _ in 0 ..< Self.maxQuoteRounds where amountSats >= MoveToSparkMath.minMoveSats {
let feeSats: UInt64
do {
feeSats = try await lightningService.estimateRoutingFees(bolt11: invoice, amountSats: amountSats, channelsOnly: true)
} catch {
if let fitting {
return fitting
}
throw MoveToSparkError.tooLarge(amountSats: amountSats)
}
let nextSats = min(wantedSats, MoveToSparkMath.amountAfterFee(limitSats: limitSats, feeSats: feeSats))
if amountSats + feeSats <= limitSats {
let quote = MoveToSparkQuote(invoice: invoice, amountSats: amountSats, feeSats: feeSats)
fitting = quote
if nextSats <= amountSats {
return quote
}
}
amountSats = nextSats
}
if let fitting {
return fitting
}
throw MoveToSparkError.tooLarge(amountSats: amountSats)
}

private static func state(for error: Error) -> MoveToSparkState {
switch error as? MoveToSparkError {
case let .tooLarge(amountSats): .tooLarge(amountSats: amountSats, smallerSats: MoveToSparkMath.smallerAmount(amountSats))
case .nothingToMove: .failed(.nothingToMove)
case .sparkUnavailable: .failed(.sparkUnavailable)
case nil: .failed(.unknown)
}
}
}
25 changes: 25 additions & 0 deletions Bitkit/Managers/TimedSheets/MoveToSparkTimedSheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// Shown once after the update to users whose channels still hold a balance worth moving to Spark.
struct MoveToSparkTimedSheet: TimedSheetItem {
let id = SheetID.moveToSpark
let priority = TimedSheetPriority.critical
let sheetItem: any SheetItem = MoveToSparkSheetItem()
let app: AppViewModel
let wallet: WalletViewModel

@MainActor
func shouldShow() async -> Bool {
guard !app.hasSeenMoveToSparkIntro else { return false }
let spark = SparkManager.shared
return MoveToSparkMath.canMove(channels: wallet.channels, isSparkUsable: MoveToSparkMath.isSparkUsable(spark))
}

func onShown() {
Task { @MainActor in
app.hasSeenMoveToSparkIntro = true
}
}

func onDismissed() {}
}
2 changes: 2 additions & 0 deletions Bitkit/Managers/TimedSheets/TimedSheetManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum TimedSheetPriority: Int, CaseIterable {
case low = 1
case medium = 2
case high = 3
case critical = 4
}

/// Protocol for timed sheet items
Expand Down Expand Up @@ -75,6 +76,7 @@ class TimedSheetManager: ObservableObject {
registerSheet(notificationsSheet)

registerSheet(UsdtIntroTimedSheet(wallet: walletViewModel, settings: settingsViewModel))
registerSheet(MoveToSparkTimedSheet(app: appViewModel, wallet: walletViewModel))

let quickpaySheet = QuickpayTimedSheet(appViewModel: appViewModel, walletViewModel: walletViewModel)
registerSheet(quickpaySheet)
Expand Down
24 changes: 24 additions & 0 deletions Bitkit/Resources/Localization/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,30 @@
"lightning__transaction" = "المعاملة";
"lightning__order_fee" = "رسوم الطلب";
"lightning__balance" = "الرصيد";
"lightning__move_to_spark__amount" = "المبلغ";
"lightning__move_to_spark__confirm_text" = "ينتقل رصيدك إلى Spark عبر Lightning، وهي أقل طريقة رسومًا للإبقاء عليه في الإنفاق. رسوم التوجيه أدناه هي أقصى ما تدفعه.";
"lightning__move_to_spark__confirm_title" = "الانتقال إلى\n<accent>Spark</accent>";
"lightning__move_to_spark__done_rest_text" = "جزء من رصيدك الآن على Spark. انقل الباقي بالطريقة نفسها، ثم أغلق الاتصال الذي أصبح فارغًا.";
"lightning__move_to_spark__done_text" = "رصيدك الآن على Spark. أغلق الاتصال الذي أصبح فارغًا لإعادة احتياطيه إلى المدخرات.";
"lightning__move_to_spark__done_title" = "تم النقل إلى\n<accent>Spark</accent>";
"lightning__move_to_spark__failed_nothing" = "لم يتبق في اتصالات Lightning لديك رصيد للنقل.";
"lightning__move_to_spark__failed_spark" = "Spark غير جاهز للاستلام بعد. حاول مرة أخرى بعد قليل.";
"lightning__move_to_spark__failed_title" = "تعذّر إتمام\n<accent>النقل</accent>";
"lightning__move_to_spark__failed_unknown" = "حدث خطأ أثناء تحضير النقل. حاول مرة أخرى.";
"lightning__move_to_spark__fee" = "رسوم التوجيه";
"lightning__move_to_spark__in_flight_text" = "الدفعة في طريقها وستظهر في Spark بمجرد إتمامها. ثم أغلق الاتصال الفارغ لإعادة احتياطيه إلى المدخرات.";
"lightning__move_to_spark__intro_text" = "يقوم Blocktank بإغلاق اتصالات Lightning الخاصة به. انقل رصيدك إلى Spark الآن: فهو الخيار الأقل رسومًا، ويبقى ضمن الإنفاق.";
"lightning__move_to_spark__intro_title" = "اتصالك على وشك\n<accent>الإغلاق</accent>";
"lightning__move_to_spark__move_half" = "نقل النصف";
"lightning__move_to_spark__move_now" = "نقل الآن";
"lightning__move_to_spark__move_rest" = "نقل الباقي";
"lightning__move_to_spark__nav_title" = "النقل إلى Spark";
"lightning__move_to_spark__quoting" = "جارٍ البحث عن مسار ورسومه…";
"lightning__move_to_spark__swipe" = "اسحب للنقل";
"lightning__move_to_spark__too_large_none" = "أغلق الاتصال بدلاً من ذلك لإعادة الرصيد إلى المدخرات.";
"lightning__move_to_spark__too_large_smaller" = "يمكنك نقله على دفعات أصغر.";
"lightning__move_to_spark__too_large_text" = "{amount} أكبر من أن يُوجَّه إلى Spark في دفعة واحدة.";
"lightning__move_to_spark__too_large_title" = "المبلغ كبير\n<accent>جدًا</accent>";
"lightning__spark" = "Spark";
"lightning__spark_claim_fee" = "رسوم المطالبة";
"lightning__spark_confirmations" = "{count}/3 تأكيدات";
Expand Down
24 changes: 24 additions & 0 deletions Bitkit/Resources/Localization/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,30 @@
"lightning__transaction" = "Transacció";
"lightning__order_fee" = "Comissió de comanda";
"lightning__balance" = "Saldo";
"lightning__move_to_spark__amount" = "Import";
"lightning__move_to_spark__confirm_text" = "El teu saldo passa a Spark per Lightning, la manera amb menys tarifes de mantenir-lo a Despesa. La tarifa d'enrutament de sota és el màxim que pagues.";
"lightning__move_to_spark__confirm_title" = "Moure a\n<accent>Spark</accent>";
"lightning__move_to_spark__done_rest_text" = "Una part del teu saldo ja és a Spark. Mou la resta de la mateixa manera i després tanca la connexió buida.";
"lightning__move_to_spark__done_text" = "El teu saldo ja és a Spark. Tanca la connexió buida per tornar-ne la reserva als Estalvis.";
"lightning__move_to_spark__done_title" = "Mogut a\n<accent>Spark</accent>";
"lightning__move_to_spark__failed_nothing" = "Les teves connexions Lightning ja no tenen saldo per moure.";
"lightning__move_to_spark__failed_spark" = "Spark encara no està a punt per rebre. Torna-ho a provar d'aquí a un moment.";
"lightning__move_to_spark__failed_title" = "No s'ha pogut\n<accent>moure</accent>";
"lightning__move_to_spark__failed_unknown" = "Alguna cosa ha anat malament en preparar el moviment. Torna-ho a provar.";
"lightning__move_to_spark__fee" = "Tarifa d'enrutament";
"lightning__move_to_spark__in_flight_text" = "El pagament està en camí i apareixerà a Spark quan s'hagi liquidat. Després tanca la connexió buida per tornar-ne la reserva als Estalvis.";
"lightning__move_to_spark__intro_text" = "Blocktank està tancant les seves connexions Lightning. Mou ara el teu saldo a Spark: és l'opció amb menys tarifes i es manté a Despesa.";
"lightning__move_to_spark__intro_title" = "La teva connexió\n<accent>es tanca</accent>";
"lightning__move_to_spark__move_half" = "Moure meitat";
"lightning__move_to_spark__move_now" = "Moure ara";
"lightning__move_to_spark__move_rest" = "Moure resta";
"lightning__move_to_spark__nav_title" = "Moure a Spark";
"lightning__move_to_spark__quoting" = "Cercant una ruta i la seva tarifa…";
"lightning__move_to_spark__swipe" = "Desplaça per moure";
"lightning__move_to_spark__too_large_none" = "Tanca la connexió per tornar el saldo als Estalvis.";
"lightning__move_to_spark__too_large_smaller" = "El pots moure en parts més petites.";
"lightning__move_to_spark__too_large_text" = "{amount} és massa gran per enrutar-se a Spark en un sol pagament.";
"lightning__move_to_spark__too_large_title" = "Import massa\n<accent>gran</accent>";
"lightning__spark" = "Spark";
"lightning__spark_claim_fee" = "Comissió de reclamació";
"lightning__spark_confirmations" = "{count}/3 confirmacions";
Expand Down
Loading
Loading