Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
77ee092
fix: preserve LNURL state across send dismissal
piotr-iohk Oct 22, 2026
732596a
docs: describe LNURL presentation regression setup
piotr-iohk Oct 22, 2026
49244c9
test: isolate cached channels in Paykit sync fallback
piotr-iohk Oct 22, 2026
ce275e3
docs: require Quick Pay off for LNURL scanner validation
piotr-iohk Oct 22, 2026
e5acf13
test: cover transient LNURL send view teardown
piotr-iohk Oct 22, 2026
5e4a3c1
fix: retain LNURL state during active send remount
piotr-iohk Oct 22, 2026
36ae9bc
chore: rename changelog fragment
piotr-iohk Oct 22, 2026
9531bd5
test: reproduce Send remount resetting payment state
piotr-iohk Oct 22, 2026
11daf5d
fix: retain Send presentation state across content recreation
piotr-iohk Oct 22, 2026
21fb09a
fix: scope retained LNURL drafts to their scan
piotr-iohk Oct 22, 2026
cdfc5b4
test: isolate Send lifecycle host from persisted channels
piotr-iohk Oct 22, 2026
fc2fad4
fix: exit lnurl screens whose payment data is gone
ovitrif Oct 22, 2026
f4e3c2a
test: reproduce external LNURL replacement clearing its target
piotr-iohk Oct 22, 2026
c725b10
fix: transfer Send state ownership only for its own scans
piotr-iohk Oct 22, 2026
c863c9c
test: reproduce contact cleanup before SwiftUI ownership updates
piotr-iohk Oct 22, 2026
a58030b
fix: close lnurl-rooted send and recheck after scan handling
ovitrif Oct 22, 2026
bbb43af
fix: release owned contact state even before UI updates
piotr-iohk Oct 22, 2026
550c402
Merge PR #1101: exit unavailable LNURL payment routes
piotr-iohk Oct 22, 2026
69a066e
fix: check contact decode ownership before changing payment data
piotr-iohk Oct 22, 2026
f70387d
test: reproduce stale image and overlapping lnurl attempts
piotr-iohk Oct 22, 2026
0fb101d
fix: retain lnurl input ownership and submission lock
piotr-iohk Oct 22, 2026
8f52f28
test: cover image payment resolution and automatic remount
piotr-iohk Oct 22, 2026
7796bd2
fix: preserve scan ownership through payment resolution
piotr-iohk Oct 22, 2026
c6bf7da
test: stabilize send lifecycle navigation transitions
piotr-iohk Oct 22, 2026
0b75670
fix: ignore photo imports superseded within send
piotr-iohk Oct 22, 2026
e888414
fix: retain quickpay attempts and cancel superseded scans
piotr-iohk Oct 22, 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
8 changes: 6 additions & 2 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -865,8 +865,10 @@ struct MainNavView: View {
)
}
} catch {
Logger.error(error, context: "Failed to read data from clipboard")
showScanError()
if !(error is CancellationError) {
Logger.error(error, context: "Failed to read data from clipboard")
showScanError()
}
}

// Clear stored URI after processing
Expand Down Expand Up @@ -976,6 +978,8 @@ struct MainNavView: View {
sheetViewModel: sheets
)
}
} catch is CancellationError {
return
} catch {
Logger.error(error, context: "Failed to handle deeplink")
showScanError()
Expand Down
108 changes: 71 additions & 37 deletions Bitkit/Managers/ScannerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ class ScannerManager: ObservableObject {
self.hwWalletManager = hwWalletManager
}

func handleScan(_ uri: String, context: ScannerContext) async {
guard !isHandlingScan else { return }
func handleScan(_ uri: String, context: ScannerContext, isCurrent: () -> Bool = { true }) async {
guard !isHandlingScan, isCurrent() else { return }
isHandlingScan = true
defer { isHandlingScan = false }

await processScan(uri, context: context)
await processScan(uri, context: context, isCurrent: isCurrent)
}

private func processScan(_ uri: String, context: ScannerContext) async {
private func processScan(_ uri: String, context: ScannerContext, isCurrent: () -> Bool) async {
let uri = uri.trimmingCharacters(in: .whitespacesAndNewlines)
guard !uri.isEmpty else { return }

Expand All @@ -66,21 +66,21 @@ class ScannerManager: ObservableObject {
handleAddContactScan(uri)
case .main:
playScanSuccess()
await handleMainScan(uri)
await handleMainScan(uri, isCurrent: isCurrent)
case .send:
await processSendScan(uri) { _ in }
await processSendScan(uri, isCurrent: isCurrent) { _ in }
case .electrum:
playScanSuccess()
await handleElectrumScan(uri)
}
}

func handleScan(_ payload: QRCodePayload, context: ScannerContext) async {
func handleScan(_ payload: QRCodePayload, context: ScannerContext, isCurrent: () -> Bool = { true }) async {
guard let uri = payload.string else {
showUnsupportedQRCodeError()
return
}
await handleScan(uri, context: context)
await handleScan(uri, context: context, isCurrent: isCurrent)
}

private func handleAddContactScan(_ input: String) {
Expand All @@ -93,7 +93,7 @@ class ScannerManager: ObservableObject {
navigation?.navigate(.addContact(publicKey: input))
}

private func handleMainScan(_ uri: String) async {
private func handleMainScan(_ uri: String, isCurrent: () -> Bool) async {
guard let app else { return }

do {
Expand All @@ -113,9 +113,10 @@ class ScannerManager: ObservableObject {

try await app.handleScannedData(
uri,
alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0
alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0,
isCurrentScan: isCurrent
)
guard shouldOpenPaymentFlow(for: uri) else { return }
guard isCurrent(), shouldOpenPaymentFlow(for: uri) else { return }

if let currency, let settings, let sheets {
PaymentNavigationHelper.openPaymentSheet(
Expand All @@ -126,6 +127,7 @@ class ScannerManager: ObservableObject {
)
}
} catch {
guard isCurrent(), !(error is CancellationError) else { return }
Logger.error(error, context: "Failed to read data from QR")
app.toast(
type: .error,
Expand Down Expand Up @@ -164,18 +166,20 @@ class ScannerManager: ObservableObject {
func handleSendScan(
_ uri: String,
scope: ScanHandlingScope = .unrestricted,
isCurrent: () -> Bool = { true },
completion: @escaping (SendRoute?) -> Void
) async {
guard !isHandlingScan else { return }
guard !isHandlingScan, isCurrent() else { return }
isHandlingScan = true
defer { isHandlingScan = false }

await processSendScan(uri, scope: scope, completion: completion)
await processSendScan(uri, scope: scope, isCurrent: isCurrent, completion: completion)
}

private func processSendScan(
_ uri: String,
scope: ScanHandlingScope = .unrestricted,
isCurrent: () -> Bool = { true },
completion: @escaping (SendRoute?) -> Void
) async {
guard let app, let currency, let settings else {
Expand Down Expand Up @@ -212,8 +216,11 @@ class ScannerManager: ObservableObject {
try await app.handleScannedData(
uri,
scope: scope,
alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0
alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0,
sendPresentationID: sheets?.activeSheetConfiguration?.presentationID,
isCurrentScan: isCurrent
)
guard isCurrent() else { return }
guard shouldOpenPaymentFlow(for: uri) else {
completion(nil)
return
Expand All @@ -227,6 +234,7 @@ class ScannerManager: ObservableObject {

completion(route)
} catch {
guard isCurrent(), !(error is CancellationError) else { return }
Logger.error(error, context: "Failed to read data from QR")
app.toast(
type: .error,
Expand All @@ -240,14 +248,15 @@ class ScannerManager: ObservableObject {
func handleSendScan(
_ payload: QRCodePayload,
scope: ScanHandlingScope = .unrestricted,
isCurrent: () -> Bool = { true },
completion: @escaping (SendRoute?) -> Void
) async {
guard let uri = payload.string else {
showUnsupportedQRCodeError()
completion(nil)
return
}
await handleSendScan(uri, scope: scope, completion: completion)
await handleSendScan(uri, scope: scope, isCurrent: isCurrent, completion: completion)
}

private func shouldOpenPaymentFlow(for uri: String) -> Bool {
Expand Down Expand Up @@ -311,34 +320,59 @@ class ScannerManager: ObservableObject {
scope: ScanHandlingScope = .unrestricted,
completion: @escaping (SendRoute?) -> Void = { _ in }
) async {
guard let app, let item else { return }
guard let item else { return }
await handleImageScan(context: context, scope: scope, decode: {
try await QRCodeImageDecoder.decode(item)
}, completion: completion)
}

func handleImageScan(
context: ScannerContext,
scope: ScanHandlingScope = .unrestricted,
decode: () async throws -> QRCodePayload,
completion: @escaping (SendRoute?) -> Void = { _ in }
) async {
guard let app else { return }
let presentationID = sheets?.activeSheetConfiguration?.presentationID
let navigationPath = navigation?.path
let inputGeneration = app.sendStateGeneration
func isCurrentOrigin() -> Bool {
!Task.isCancelled && sheets?.activeSheetConfiguration?.presentationID == presentationID &&
Comment thread
piotr-iohk marked this conversation as resolved.
Resolved
navigation?.path == navigationPath
}

do {
let payload = try await QRCodeImageDecoder.decode(item)
let payload = try await decode()
guard isCurrentOrigin(), context != .send || app.sendStateGeneration == inputGeneration else { return }
if context == .send {
await handleSendScan(payload, scope: scope, completion: completion)
await handleSendScan(payload, scope: scope, isCurrent: isCurrentOrigin, completion: completion)
} else {
await handleScan(payload, context: context)
await handleScan(payload, context: context, isCurrent: isCurrentOrigin)
}
} catch QRCodeImageDecoderError.invalidImage {
app.toast(
type: .error,
title: t("common__error"),
description: t("other__qr_error_load_image")
)
} catch QRCodeImageDecoderError.noQRCode {
app.toast(
type: .error,
title: t("other__qr_error_no_qr_title"),
description: t("other__qr_error_no_qr_description")
)
} catch {
Logger.error(error, context: "Failed to process image")
app.toast(
type: .error,
title: t("other__qr_error_detection_title"),
description: t("other__qr_error_detection_description")
)
guard isCurrentOrigin(), context != .send || app.sendStateGeneration == inputGeneration,
!(error is CancellationError) else { return }
switch error {
case QRCodeImageDecoderError.invalidImage:
app.toast(
type: .error,
title: t("common__error"),
description: t("other__qr_error_load_image")
)
case QRCodeImageDecoderError.noQRCode:
app.toast(
type: .error,
title: t("other__qr_error_no_qr_title"),
description: t("other__qr_error_no_qr_description")
)
default:
Logger.error(error, context: "Failed to process image")
app.toast(
type: .error,
title: t("other__qr_error_detection_title"),
description: t("other__qr_error_detection_description")
)
}
}
}

Expand Down
46 changes: 38 additions & 8 deletions Bitkit/Utilities/QuickPayPaymentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import LDKNode
final class QuickPayPaymentCoordinator {
static let shared = QuickPayPaymentCoordinator()

struct SessionID: Equatable {
let presentationID: UUID
let sendStateGeneration: UUID
let attempt: Int
}

struct Presentation {
var appendRoute: (SendRoute) -> Void
var replaceQuickPay: (SendRoute) -> Void
Expand Down Expand Up @@ -44,6 +50,10 @@ final class QuickPayPaymentCoordinator {
private var operations: [String: InFlightOp] = [:]
private var generation = UUID()
private var payRequested = false
private var sessionID: SessionID?
private var activePresentation: Presentation?
/// Settlement can remove the ledger entry before dispatch returns its UI result.
private var currentOperation: InFlightOp?

var liveSubmittingHashes: Set<String> {
Set(operations.values.map(\.invoiceHash))
Expand Down Expand Up @@ -71,6 +81,10 @@ final class QuickPayPaymentCoordinator {
func detach() {
generation = UUID()
payRequested = false
sessionID = nil
activePresentation = nil
currentOperation?.presentation = nil
currentOperation = nil
for op in uniqueOps {
op.presentation = nil
}
Expand All @@ -81,19 +95,31 @@ final class QuickPayPaymentCoordinator {
wallet: WalletViewModel,
settings: SettingsViewModel,
currency: CurrencyViewModel,
presentation: Presentation
presentation: Presentation,
sessionID: SessionID? = nil
) {
if let sessionID {
if self.sessionID == sessionID {
activePresentation = presentation
currentOperation?.presentation = presentation
return
}
detach()
self.sessionID = sessionID
}
guard !payRequested else { return }
payRequested = true
activePresentation = presentation
let capturedGeneration = generation
let sendStateGeneration = app.sendStateGeneration
Task {
await run(
generation: capturedGeneration,
sendStateGeneration: sendStateGeneration,
app: app,
wallet: wallet,
settings: settings,
currency: currency,
presentation: presentation
currency: currency
)
}
}
Expand Down Expand Up @@ -190,13 +216,13 @@ final class QuickPayPaymentCoordinator {

private func run(
generation: UUID,
sendStateGeneration: UUID,
app: AppViewModel,
wallet: WalletViewModel,
settings: SettingsViewModel,
currency: CurrencyViewModel,
presentation: Presentation
currency: CurrencyViewModel
) async {
guard generation == self.generation else { return }
guard generation == self.generation, app.sendStateGeneration == sendStateGeneration else { return }

var bolt11: String?
do {
Expand All @@ -211,12 +237,14 @@ final class QuickPayPaymentCoordinator {
bolt11 = scannedInvoice.bolt11
}
} catch {
guard generation == self.generation else { return }
guard generation == self.generation, app.sendStateGeneration == sendStateGeneration,
let presentation = activePresentation else { return }
fail(presentation, error: error, bolt11: nil)
return
}

guard generation == self.generation else { return }
guard generation == self.generation, app.sendStateGeneration == sendStateGeneration,
let presentation = activePresentation else { return }
guard let bolt11 else {
fail(presentation, error: AppError(message: t("common__error_body"), debugMessage: "No Lightning invoice found"), bolt11: nil)
return
Expand All @@ -233,6 +261,7 @@ final class QuickPayPaymentCoordinator {
app.beginQuickPay(paymentHash: invoiceHash)

if let existing = operations[invoiceHash] {
currentOperation = existing
existing.presentation = presentation
if existing.emitted {
replayPending(existing)
Expand Down Expand Up @@ -463,6 +492,7 @@ final class QuickPayPaymentCoordinator {
}

private func register(_ op: InFlightOp) {
currentOperation = op
operations[op.invoiceHash] = op
if let paymentId = op.paymentId, !paymentId.isEmpty, paymentId != op.invoiceHash {
operations[paymentId] = op
Expand Down
Loading
Loading