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
5 changes: 5 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1747,3 +1747,8 @@
"settings__adv__pp_both" = "Both";
"settings__adv__pp_lightning_short" = "Lightning";
"settings__adv__pp_onchain_short" = "On-chain";
"wallet__receive_offline" = "Receive Offline";
"wallet__receive_offline_description" = "Prepare this invoice to receive while Bitkit is closed.";
"wallet__receive_offline_unavailable" = "Offline receiving is unavailable. Try again or turn off Receive Offline.";
"wallet__receive_offline_liquidity" = "The amount is outside your available offline receiving liquidity.";
"wallet__receive_offline_failed" = "Could not prepare an offline invoice. Please try again.";
57 changes: 57 additions & 0 deletions Bitkit/Services/OfflineReceiveRegistration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation
import LDKNode
import Observation

@MainActor
@Observable
final class OfflineReceiveRegistration {
private let payments: @MainActor () async -> [PaymentDetails]?
private var paymentRevision = UUID()
private var invoice: OfflineReceiveInvoice?
private(set) var revision = UUID()

init(payments: @escaping @MainActor () async -> [PaymentDetails]?) {
self.payments = payments
}

func reset() {
revision = UUID()
invoice = nil
}

func beginPreparation() -> UUID {
reset()
return revision
}

func paymentReceived(hash: String) {
paymentRevision = UUID()
if invoice?.paymentHash == hash { invoice = nil }
}

func contains(_ invoice: OfflineReceiveInvoice) -> Bool {
self.invoice == invoice && invoice.expiresAt > .now
}

func register(_ invoice: OfflineReceiveInvoice, revision: UUID) async throws {
while true {
try Task.checkCancellation()
guard self.revision == revision else { throw OfflineReceiveError.unavailable }
let paymentRevision = paymentRevision
guard let payments = await payments() else { throw OfflineReceiveError.unavailable }
try Task.checkCancellation()
guard self.revision == revision, invoice.expiresAt > .now else { throw OfflineReceiveError.unavailable }
let paid = payments.contains { payment in
guard payment.direction == .inbound, payment.status == .succeeded,
case let .bolt11(hash, _, _, _, _) = payment.kind
else { return false }
return hash == invoice.paymentHash
}
guard !paid else { throw OfflineReceiveError.unavailable }
if self.paymentRevision == paymentRevision {
self.invoice = invoice
return
}
}
}
}
165 changes: 165 additions & 0 deletions Bitkit/Services/OfflineReceiveSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import Foundation
import Observation

struct PreparedOfflineInvoice {
let bolt11: String
}

struct OfflineReceiveInvoice: Equatable {
let bolt11: String
let amountSats: UInt64
let note: String
let paymentHash: String
let expiresAt: Date

func canDisplay(amountSats: UInt64, note: String, now: Date = .now) -> Bool {
self.amountSats == amountSats && self.note == note && expiresAt > now
}
}

@MainActor
protocol OfflineReceiveProviding {
func canReceive(amountSats: UInt64) async throws -> Bool

/// Returns only after FFOR activation and recovery data are durably stored.
/// Implementations must not return an ordinary Lightning invoice on failure.
/// The native provider chooses an expiry permitted by its negotiated policy.
func prepareInvoice(requestId: String, amountSats: UInt64, description: String) async throws -> PreparedOfflineInvoice
}

enum OfflineReceiveError: LocalizedError {
case unavailable
case insufficientLiquidity
case invalidInvoice

var errorDescription: String? {
switch self {
case .unavailable: t("wallet__receive_offline_unavailable")
case .insufficientLiquidity: t("wallet__receive_offline_liquidity")
case .invalidInvoice: t("wallet__receive_offline_failed")
}
}
}

struct UnavailableOfflineReceiveProvider: OfflineReceiveProviding {
nonisolated init() {}

func canReceive(amountSats _: UInt64) async throws -> Bool { false }

func prepareInvoice(requestId _: String, amountSats _: UInt64, description _: String) async throws -> PreparedOfflineInvoice {
throw OfflineReceiveError.unavailable
}
}

struct OfflineReceiveEligibility: Equatable, Hashable {
let amountSats: UInt64
let inboundCapacitySats: UInt64?
let isNodeRunning: Bool
let supportsLightning: Bool

var hasLiquidity: Bool {
guard supportsLightning, isNodeRunning, amountSats > 0, let inboundCapacitySats else { return false }
return amountSats <= inboundCapacitySats
}
}

@MainActor
@Observable
final class OfflineReceiveSession {
private let provider: any OfflineReceiveProviding
private var eligibility: OfflineReceiveEligibility?
private var revision = UUID()
private var preparation: Preparation?
private(set) var isEligible = false
private(set) var isSelected = false

private final class Preparation {
let requestId = UUID().uuidString
let amountSats: UInt64
let description: String
var attempted = false

init(amountSats: UInt64, description: String) {
self.amountSats = amountSats
self.description = description
}
}

init(provider: any OfflineReceiveProviding) {
self.provider = provider
}

func updateEligibility(_ eligibility: OfflineReceiveEligibility) async {
let revision = UUID()
self.revision = revision
let hasAttempt = preparation?.attempted == true && preparation?.amountSats == eligibility.amountSats
if self.eligibility?.amountSats != eligibility.amountSats || !eligibility.supportsLightning || (!eligibility.hasLiquidity && !hasAttempt) {
isSelected = false
}
self.eligibility = eligibility
isEligible = false
guard eligibility.hasLiquidity else { return }

let supported = await (try? provider.canReceive(amountSats: eligibility.amountSats)) == true
guard self.revision == revision, !Task.isCancelled else { return }
isEligible = supported
if !supported, !(preparation?.attempted == true && preparation?.amountSats == eligibility.amountSats) {
isSelected = false
}
}

func setSelected(_ selected: Bool) {
let hasAttempt = preparation?.attempted == true && preparation?.amountSats == eligibility?.amountSats
isSelected = selected && (isEligible || hasAttempt)
}

func canSelect(for eligibility: OfflineReceiveEligibility) -> Bool {
isEligible && self.eligibility == eligibility
}

func reset() {
revision = UUID()
eligibility = nil
isEligible = false
isSelected = false
preparation = nil
}

func hasPreparationAttempt(amountSats: UInt64, description: String) -> Bool {
preparation?.attempted == true && preparation?.amountSats == amountSats && preparation?.description == description
}

func expirePreparation() {
preparation = nil
}

func prepareInvoice(
eligibility: OfflineReceiveEligibility,
description: String
) async throws -> PreparedOfflineInvoice {
let preparation: Preparation
if let current = self.preparation,
current.amountSats == eligibility.amountSats,
current.description == description
{
preparation = current
} else {
preparation = Preparation(amountSats: eligibility.amountSats, description: description)
self.preparation = preparation
}
if !preparation.attempted {
guard eligibility.hasLiquidity else { throw OfflineReceiveError.insufficientLiquidity }
guard try await provider.canReceive(amountSats: eligibility.amountSats) else { throw OfflineReceiveError.unavailable }
}
try Task.checkCancellation()
preparation.attempted = true
let invoice = try await provider.prepareInvoice(
requestId: preparation.requestId,
amountSats: eligibility.amountSats,
description: description
)
try Task.checkCancellation()
guard !invoice.bolt11.isEmpty else { throw OfflineReceiveError.invalidInvoice }
return invoice
}
}
Loading
Loading