From 6f50ef8405589751e79e61dca43c5cda9e5169b6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Oct 2026 10:29:02 -0300 Subject: [PATCH 01/19] fix: pay first onchain subscription period in one swipe Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 8 ++---- BitkitTests/SendConfirmationViewTests.swift | 28 ++++++++----------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index cfdec8cfd..5396cb8c4 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -219,8 +219,7 @@ struct SendConfirmationView: View { } private var shouldAutomaticallyPay: Bool { - retainedHardwareRetry == nil && preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true && app - .selectedWalletToPayFrom == .lightning && + retainedHardwareRetry == nil && preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true && !hwSend.isActive && !requiresPaymentConfirmation } @@ -844,8 +843,8 @@ struct SendConfirmationView: View { } } - static func requiresManualConfirmation(isAutomatic: Bool, walletType: WalletType, isHardwarePayment: Bool) -> Bool { - isAutomatic && (walletType != .lightning || isHardwarePayment) + static func requiresManualConfirmation(isAutomatic: Bool, isHardwarePayment: Bool) -> Bool { + isAutomatic && isHardwarePayment } static func privatePaymentListOutcomeForLightningFailure( @@ -989,7 +988,6 @@ struct SendConfirmationView: View { private func requiresManualConfirmation(isAutomatic: Bool) -> Bool { Self.requiresManualConfirmation( isAutomatic: isAutomatic, - walletType: app.selectedWalletToPayFrom, isHardwarePayment: hwSend.isActive ) } diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index 54876bdab..87cfe9dbd 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -14,23 +14,19 @@ final class SendConfirmationViewTests: XCTestCase { XCTAssertFalse(SendConfirmationView.canEditConfirmationFee(retained: nil, isLoading: true)) } - func testAutomaticPaymentRequiresManualConfirmationForNonLightningFunding() { - for walletType in [WalletType.lightning, .onchain] { - for isHardwarePayment in [false, true] { - XCTAssertEqual( - SendConfirmationView.requiresManualConfirmation( - isAutomatic: true, - walletType: walletType, - isHardwarePayment: isHardwarePayment - ), - walletType == .onchain || isHardwarePayment - ) - XCTAssertFalse(SendConfirmationView.requiresManualConfirmation( - isAutomatic: false, - walletType: walletType, + func testAutomaticPaymentRequiresManualConfirmationOnlyForHardwareFunding() { + for isHardwarePayment in [false, true] { + XCTAssertEqual( + SendConfirmationView.requiresManualConfirmation( + isAutomatic: true, isHardwarePayment: isHardwarePayment - )) - } + ), + isHardwarePayment + ) + XCTAssertFalse(SendConfirmationView.requiresManualConfirmation( + isAutomatic: false, + isHardwarePayment: isHardwarePayment + )) } } From 0a6334769f4adb4b084bd8f03cc3d78dda3d25c5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Oct 2026 10:29:02 -0300 Subject: [PATCH 02/19] fix: match subscription review to figma and keep it while paying Co-Authored-By: Claude Opus 5.5 --- Bitkit/Components/SwipeButton.swift | 11 +- .../Localization/en.lproj/Localizable.strings | 1 - .../Subscriptions/SubscriptionsView.swift | 120 ++++++++++-------- .../InitialSubscriptionPaymentProgress.swift | 22 +++- 4 files changed, 97 insertions(+), 57 deletions(-) diff --git a/Bitkit/Components/SwipeButton.swift b/Bitkit/Components/SwipeButton.swift index 850055ea9..0f7a42fe6 100644 --- a/Bitkit/Components/SwipeButton.swift +++ b/Bitkit/Components/SwipeButton.swift @@ -7,6 +7,8 @@ struct SwipeButton: View { var isDisabled = false /// Blocks interaction and shows the knob spinner while an operation is running. var isLoading = false + /// Shows the knob at the end of the track, as after a completed swipe. + var isConfirmed = false /// Optional binding for swipe progress (0...1), e.g. to drive animations in the parent. var swipeProgress: Binding? let onComplete: () async throws -> Void @@ -28,9 +30,10 @@ struct SwipeButton: View { var body: some View { GeometryReader { geometry in let maxOffset = max(1, geometry.size.width - buttonHeight) - let clampedOffset = max(0, min(offset, geometry.size.width - buttonHeight)) + let knobOffset = isConfirmed ? maxOffset : offset + let clampedOffset = max(0, min(knobOffset, geometry.size.width - buttonHeight)) let trailWidth = max(0, min(clampedOffset + (buttonHeight - innerPadding), geometry.size.width - innerPadding)) - let textProgress = offset / maxOffset + let textProgress = knobOffset / maxOffset let halfWidth = geometry.size.width / 2 ZStack(alignment: .leading) { @@ -69,13 +72,13 @@ struct SwipeButton: View { .resizable() .frame(width: 24, height: 24) .foregroundColor(.gray7) - .opacity(Double(1.0 - (offset / halfWidth))) + .opacity(Double(1.0 - (knobOffset / halfWidth))) Image("check-mark") .resizable() .frame(width: 32, height: 32) .foregroundColor(.gray7) - .opacity(Double(max(0, (offset - halfWidth) / halfWidth))) + .opacity(Double(max(0, (knobOffset - halfWidth) / halfWidth))) } } ) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index c273e1dc6..77834a5cc 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1598,7 +1598,6 @@ "subscriptions__expires" = "Expires"; "subscriptions__first_payment_failed" = "First Payment Failed"; "subscriptions__first_payment_failed_description" = "You’re subscribed, but your first payment wasn’t sent."; -"subscriptions__first_period_ends" = "First billing period ends {date}. Each period is charged in full."; "subscriptions__expires_date" = "Expires {date}"; "subscriptions__frequency" = "Frequency"; "subscriptions__more_info" = "More Info"; diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 7ac8e7f1a..3aea711dc 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -643,62 +643,24 @@ struct SubscriptionSheet: View { } private func review(_ subscription: PaykitSubscription) -> some View { - let payOnAcceptance = subscription.paymentDueOnAcceptance(at: now, acceptedAt: Date()) != nil - return VStack(spacing: 0) { - SheetHeader(title: t("subscriptions__review_and_subscribe")) - SubscriptionAmountHeader(subscription: subscription) - SubscriptionProviderCard(subscription: subscription, showsCounterparty: true) { + SubscriptionReviewContent( + subscription: subscription, + now: now, + isLoading: isAccepting || paymentRequests.isProcessingSubscription, + onDetails: { guard !isAccepting else { return } previousRoute = route route = .details(subscription) - } - .allowsHitTesting(!isAccepting) - - if let period = subscription.paymentDueOnAcceptance(at: now, acceptedAt: Date())?.billingPeriod { - BodySText( - t("subscriptions__first_period_ends", variables: ["date": period.endsAt.formatted(date: .abbreviated, time: .shortened)]), - textColor: .white64 - ) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) - } - - if !subscription.recurrence.unit.isSupported { - BodyMText(t("subscriptions__unsupported_description"), textColor: .white64) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) - } else if subscription.hasPaymentDeadline || subscription.acceptedPaymentEndpointIdentifiers.isEmpty { - BodyMText(t("subscriptions__unsupported_payment_description"), textColor: .white64) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) - } - - Spacer() - Image("subscription-clock") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 256, height: 256) - .accessibilityHidden(true) - Spacer() - - if subscription.isProposalActionable(at: now) { - SwipeButton( - title: payOnAcceptance - ? t("subscriptions__swipe_to_subscribe_and_pay") - : t("subscriptions__swipe_to_subscribe"), - accentColor: .brandAccent, - isLoading: isAccepting || paymentRequests.isProcessingSubscription - ) { - do { - try await accept(subscription) - } catch { - app.toast(error) - throw error - } + }, + onSubscribe: { + do { + try await accept(subscription) + } catch { + app.toast(error) + throw error } } - } - .padding(.horizontal, 16) + ) } private func success() -> some View { @@ -944,6 +906,62 @@ struct SubscriptionSuccessView: View { } } +struct SubscriptionReviewContent: View { + /// Figma draws the review clock at 288pt inside its 256pt slot, rotated 15 degrees and shifted 7pt left. + private static let clockScale: CGFloat = 288.0 / 256.0 + private static let clockRotation = Angle.degrees(15) + private static let clockOffsetX: CGFloat = -7 + + let subscription: PaykitSubscription + var now = SubscriptionClock.subscriptionNow() + var isLoading = false + /// Shows the swipe as already completed, for the first payment that follows acceptance. + var isPaying = false + var onDetails: () -> Void = {} + var onSubscribe: () async throws -> Void = {} + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__review_and_subscribe")) + SubscriptionAmountHeader(subscription: subscription) + SubscriptionProviderCard(subscription: subscription, showsCounterparty: true, action: onDetails) + .allowsHitTesting(!isLoading && !isPaying) + + if !subscription.recurrence.unit.isSupported { + BodyMText(t("subscriptions__unsupported_description"), textColor: .white64) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 16) + } else if subscription.hasPaymentDeadline || subscription.acceptedPaymentEndpointIdentifiers.isEmpty { + BodyMText(t("subscriptions__unsupported_payment_description"), textColor: .white64) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 16) + } + + Spacer() + Image("subscription-clock") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .scaleEffect(Self.clockScale) + .rotationEffect(Self.clockRotation) + .offset(x: Self.clockOffsetX) + .accessibilityHidden(true) + Spacer() + + if isLoading || isPaying || subscription.isProposalActionable(at: now) { + SwipeButton( + title: t("subscriptions__swipe_to_subscribe"), + accentColor: .brandAccent, + isLoading: isLoading || isPaying, + isConfirmed: isPaying, + onComplete: onSubscribe + ) + } + } + .padding(.horizontal, 16) + } +} + private struct SubscriptionAmountHeader: View { let subscription: PaykitSubscription diff --git a/Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift b/Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift index b3938eaa7..ec83d9d80 100644 --- a/Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift +++ b/Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift @@ -1,7 +1,28 @@ import SwiftUI struct InitialSubscriptionPaymentProgress: View { + @EnvironmentObject private var app: AppViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + private var subscription: PaykitSubscription? { + guard let request = app.contactPaymentContext?.incomingPaymentRequest else { return nil } + return paymentRequests.subscriptions.first { + $0.paymentRequestId == request.paymentRequestId && $0.counterparty == request.counterparty + } + } + var body: some View { + Group { + if let subscription { + SubscriptionReviewContent(subscription: subscription, isPaying: true) + } else { + progressIndicator + } + } + .sheetBackground() + } + + private var progressIndicator: some View { VStack(spacing: 0) { SheetHeader( title: t("subscriptions__review_and_subscribe"), @@ -14,7 +35,6 @@ struct InitialSubscriptionPaymentProgress: View { Spacer() } .padding(.horizontal, 16) - .sheetBackground() } } From 6637204bc2b5ed946c08cf70b4c8af7c36b1c035 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Oct 2026 10:29:02 -0300 Subject: [PATCH 03/19] docs: update subscribe journey for single swipe Co-Authored-By: Claude Opus 5.5 --- journeys/subscriptions/review-and-subscribe.xml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index 05caab154..b82e5b65f 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -2,14 +2,16 @@ Payer side of create-and-propose.xml. The proposed subscription arrives, surfaces under PROPOSALS, and opens the Review and Subscribe sheet where the payer swipes to accept. Accepting does not - return to the list on its own: when the first period is due on acceptance the send flow starts and - must be completed, otherwise the Subscribed confirmation opens directly. Both paths end on Close. + return to the list on its own: when the first period is due on acceptance the first payment starts + by itself, from Lightning or on-chain savings, with no second swipe; otherwise the Subscribed + confirmation opens directly. Both paths end on Close. A hardware wallet still shows the send + confirmation for the first payment. Run this on the payer instance immediately after the creator sends the proposal. iOS shows more on the review sheet than Android does: the provider card carries the counterparty's - contact name and truncated pubky (id "SubscriptionCounterparty") under the cadence, and a line - naming when the first billing period ends sits below the card. Neither appears on Android. The - handoff's "Automatically pay this subscription" toggle is unbuilt on both platforms. + contact name and truncated pubky (id "SubscriptionCounterparty") under the cadence, which does not + appear on Android. The handoff's "Automatically pay this subscription" toggle is unbuilt on both + platforms. The swipe knob is brand orange on both platforms, as the handoff draws it on all four subscription sheets. @@ -21,10 +23,10 @@ Verify a row for "Journey Sub" appears under the PROPOSALS section Tap the "Journey Sub" row (id "SubscriptionRow-<paymentRequestId>") Verify the Review and Subscribe sheet appears with 5,000 sats and the creator contact card - Verify the swipe control reads "Swipe To Subscribe & Pay" when the first period is due on acceptance, otherwise "Swipe To Subscribe" + Verify the swipe control reads "Swipe To Subscribe", whether or not the first period is due on acceptance Verify the swipe knob is brand orange, as on the cancel and delete sheets Swipe the confirm control to the end - If the first period was due on acceptance: complete the send flow that opens, then verify the Subscribed confirmation appears + If the first period was due on acceptance: verify the Review and Subscribe layout stays on screen with the swipe control in its loading state and no second swipe is asked for, wait for the payment to send, then verify the Subscribed confirmation appears If no payment was due: verify the Subscribed confirmation appears directly, with no send flow Tap Close on the Subscribed confirmation If a payment was made: verify its billing period is no longer offered with Pay or Dismiss, no Payment Requests sheet opens for it, and the home payment-request bell is absent when no other requests are pending From 91015542ad7888c1fa040578798aa3a8da884eff Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 6 Oct 2026 10:29:02 -0300 Subject: [PATCH 04/19] chore: add subscription single swipe changelog fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/next/subscription-onchain-single-swipe.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/subscription-onchain-single-swipe.changed.md diff --git a/changelog.d/next/subscription-onchain-single-swipe.changed.md b/changelog.d/next/subscription-onchain-single-swipe.changed.md new file mode 100644 index 000000000..a227b3d20 --- /dev/null +++ b/changelog.d/next/subscription-onchain-single-swipe.changed.md @@ -0,0 +1 @@ +Accepting a subscription now sends the first payment in the same swipe when it is paid on-chain, as it already did over Lightning. From 8cee06bbbe54cf47690187fc686c32947a916c73 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 07:57:37 -0300 Subject: [PATCH 05/19] fix: start first subscription payment after send setup and fee Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 37 ++++++++++++++++++- Bitkit/Views/Wallets/Send/SendSheet.swift | 10 ++++- BitkitTests/SendConfirmationViewTests.swift | 6 +++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 5396cb8c4..679e37dad 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -22,6 +22,8 @@ struct SendConfirmationView: View { let prepareIncomingPaymentRequest: () async throws -> Void let routingCacheResetAttempted: Bool var preparingRequest: PaykitPaymentRequest? + /// True while the send sheet is still loading the fee rate and choosing the funding source. + var isSetupPending = false @State private var showDetails = false @State private var showingBiometricError = false @@ -378,6 +380,16 @@ struct SendConfirmationView: View { // PIN navigation can cancel the view task while authorization is awaiting its result. Task { @MainActor in await startAutomaticPaymentIfNeeded() } } + .onChange(of: isSetupPending) { _, isPending in + guard !isPending, preparingRequest == nil else { return } + Task { @MainActor in + if app.contactPaymentContext?.isInitialSubscriptionPayment == true, !shouldAutomaticallyPay { + requiresPaymentConfirmation = true + showDetails = true + } + await startAutomaticPaymentIfNeeded() + } + } .onChange(of: wallet.selectedFeeRateSatsPerVByte) { guard preparingRequest == nil else { return } Task { @@ -457,7 +469,7 @@ struct SendConfirmationView: View { @MainActor private func startAutomaticPaymentIfNeeded() async { - guard shouldAutomaticallyPay, !hasStartedAutomaticPayment + guard shouldAutomaticallyPay, !hasStartedAutomaticPayment, !isSetupPending else { return } hasStartedAutomaticPayment = true do { @@ -783,6 +795,14 @@ struct SendConfirmationView: View { try await wallet.setFeeRate(speed: settings.defaultTransactionSpeed) } + if isAutomatic { + await settleTransactionFee() + if Self.automaticPaymentLacksFee(walletType: app.selectedWalletToPayFrom, transactionFee: transactionFee) { + showManualConfirmation() + return + } + } + // Validate payment and show warnings if needed let warnings = await validatePayment() if !warnings.isEmpty { @@ -847,6 +867,11 @@ struct SendConfirmationView: View { isAutomatic && isHardwarePayment } + /// An automatic on-chain payment must not be sent before its fee is known, because the fee warnings read it. + static func automaticPaymentLacksFee(walletType: WalletType, transactionFee: Int) -> Bool { + walletType == .onchain && transactionFee <= 0 + } + static func privatePaymentListOutcomeForLightningFailure( paymentSubmitted: Bool, proofFailureWasDefinite: Bool @@ -1686,6 +1711,16 @@ struct SendConfirmationView: View { await calculateTransactionFee() } + /// Recalculates until the newest calculation is the one that finished, so a concurrent one cannot leave the fee unset. + @MainActor + private func settleTransactionFee() async { + for _ in 0 ..< 3 { + let startedFrom = feeCalculationId + await calculateTransactionFee() + if feeCalculationId == startedFrom + 1 { return } + } + } + @MainActor private func calculateTransactionFee() async { guard !Task.isCancelled, let context = confirmationContext else { return } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index d77048145..f20cb85bc 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -141,6 +141,7 @@ struct SendSheet: View { @State private var pinCheckContinuations: [CheckedContinuation] = [] @State private var hwSend: HwSendCoordinator @State private var setupTask: Task? + @State private var isSetupPending = false init(config: SendSheetItem, isEmbedded: Bool = false) { self.config = config @@ -381,7 +382,13 @@ struct SendSheet: View { app.resetSendState() } setupTask?.cancel() + isSetupPending = true setupTask = Task { + defer { + if !Task.isCancelled { + isSetupPending = false + } + } if incomingPaymentRequest == nil, config.hardwareWalletId == nil, currentRoot == .options || app.selectedWalletToPayFrom == .onchain { @@ -803,7 +810,8 @@ struct SendSheet: View { requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, routingCacheResetAttempted: routingCacheResetAttempted, - preparingRequest: isPreparingRequest ? config.preparation?.request : nil + preparingRequest: isPreparingRequest ? config.preparation?.request : nil, + isSetupPending: isSetupPending ) case .hardwareSign: let contactContext = app.contactPaymentContext diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index 87cfe9dbd..a680936d3 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -30,6 +30,12 @@ final class SendConfirmationViewTests: XCTestCase { } } + func testAutomaticPaymentLacksFeeOnlyForOnchainWithoutCalculatedFee() { + XCTAssertTrue(SendConfirmationView.automaticPaymentLacksFee(walletType: .onchain, transactionFee: 0)) + XCTAssertFalse(SendConfirmationView.automaticPaymentLacksFee(walletType: .onchain, transactionFee: 143)) + XCTAssertFalse(SendConfirmationView.automaticPaymentLacksFee(walletType: .lightning, transactionFee: 0)) + } + func testLightningFailureReleasesOnlyDefinitePreSubmissionAttempts() { XCTAssertEqual( SendConfirmationView.privatePaymentListOutcomeForLightningFailure( From c6a8d1cf0963de0f5f52db6c73e7e8d355f4c46e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 08:24:20 -0300 Subject: [PATCH 06/19] fix: show one subtitle line on subscription review card Co-Authored-By: Claude Opus 5.5 --- .../Subscriptions/SubscriptionsView.swift | 18 +----------------- .../subscriptions/review-and-subscribe.xml | 5 +---- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 3aea711dc..3d264a54e 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -924,7 +924,7 @@ struct SubscriptionReviewContent: View { VStack(spacing: 0) { SheetHeader(title: t("subscriptions__review_and_subscribe")) SubscriptionAmountHeader(subscription: subscription) - SubscriptionProviderCard(subscription: subscription, showsCounterparty: true, action: onDetails) + SubscriptionProviderCard(subscription: subscription, action: onDetails) .allowsHitTesting(!isLoading && !isPaying) if !subscription.recurrence.unit.isSupported { @@ -972,17 +972,10 @@ private struct SubscriptionAmountHeader: View { } private struct SubscriptionProviderCard: View { - @EnvironmentObject private var contactsManager: ContactsManager - let subscription: PaykitSubscription - var showsCounterparty = false var subtitle: String? var action: (() -> Void)? - private var contact: PubkyContact? { - contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, subscription.counterparty) } - } - var body: some View { if let action { Button(action: action) { @@ -1002,15 +995,6 @@ private struct SubscriptionProviderCard: View { .lineLimit(1) CaptionText(subtitle ?? subscription.recurrence.subscriptionFrequencyLabel, textColor: .white64) .lineLimit(1) - if showsCounterparty { - if let contact { - CaptionText(contact.displayName, textColor: .white64) - .lineLimit(1) - } - CaptionText(PubkyPublicKeyFormat.displayTruncated(subscription.counterparty), textColor: .white64) - .lineLimit(1) - .accessibilityIdentifier("SubscriptionCounterparty") - } } Spacer() if showsChevron { diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index b82e5b65f..ed95815bb 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -8,10 +8,7 @@ confirmation for the first payment. Run this on the payer instance immediately after the creator sends the proposal. - iOS shows more on the review sheet than Android does: the provider card carries the counterparty's - contact name and truncated pubky (id "SubscriptionCounterparty") under the cadence, which does not - appear on Android. The handoff's "Automatically pay this subscription" toggle is unbuilt on both - platforms. + The handoff's "Automatically pay this subscription" toggle is unbuilt on both platforms. The swipe knob is brand orange on both platforms, as the handoff draws it on all four subscription sheets. From 5d979ab397dd22a6393be385814cf7e891b50bea Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 13:06:40 -0300 Subject: [PATCH 07/19] chore: name subscription changelog fragment after pr Co-Authored-By: Claude Opus 5.5 --- ...ubscription-onchain-single-swipe.changed.md => 881.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{subscription-onchain-single-swipe.changed.md => 881.changed.md} (100%) diff --git a/changelog.d/next/subscription-onchain-single-swipe.changed.md b/changelog.d/next/881.changed.md similarity index 100% rename from changelog.d/next/subscription-onchain-single-swipe.changed.md rename to changelog.d/next/881.changed.md From cb85d60e7f90d3fff1e9470a782b34f28c354c2d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 13:08:03 -0300 Subject: [PATCH 08/19] fix: open manual confirmation when fee rate is missing Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/Send/SendConfirmationView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 679e37dad..26383a06e 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -769,6 +769,10 @@ struct SendConfirmationView: View { private func submitPayment(isAutomatic: Bool = false) async throws { guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() } + if isAutomatic, isFeeRateMissing { + showManualConfirmation() + return + } try await Self.requireManualCoinSelection( walletType: app.selectedWalletToPayFrom, isHardwarePayment: hwSend.isActive, From 08f4786048910af2aaa79227dc1e90afc083c003 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 8 Oct 2026 13:08:04 -0300 Subject: [PATCH 09/19] docs: keep subscription journeys valid with single swipe Co-Authored-By: Claude Opus 5.5 --- .../cancellation-during-confirmation.xml | 15 ++++++++++----- journeys/subscriptions/review-and-subscribe.xml | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/journeys/subscriptions/cancellation-during-confirmation.xml b/journeys/subscriptions/cancellation-during-confirmation.xml index 2b5a6fa5f..ab503f763 100644 --- a/journeys/subscriptions/cancellation-during-confirmation.xml +++ b/journeys/subscriptions/cancellation-during-confirmation.xml @@ -1,16 +1,21 @@ - Requires a linked regtest issuer and a payer funded for a 5,000-sat on-chain payment. + Requires a linked regtest issuer and a payer funded for two 5,000-sat on-chain payments. The issuer can cancel an accepted recurring request with a fixed P2WPKH endpoint and no - payment deadline. Verifies cancellation while confirmation is open. Cancellation after payment - preparation or during an authorization lookup is covered by unit tests. + payment deadline. Verifies cancellation while confirmation is open. The first period is paid by + the acceptance swipe itself, so the open confirmation is reached on the next period by advancing + the subscription clock offset. Cancellation after payment preparation or during an authorization + lookup is covered by unit tests. Have the issuer propose a monthly subscription for 5,000 sats with no payment deadline and an explicit regtest P2WPKH endpoint - Accept the proposal in Bitkit and open its unpaid period to the send confirmation screen without confirming payment + Accept the proposal in Bitkit and wait for the first period's payment to send and the Subscribed confirmation to appear, then close it + Advance the subscription clock offset to the next monthly period in Dev Settings, then refresh Subscriptions + Open the next unpaid period to the send confirmation screen without confirming payment Have the issuer cancel the subscription and keep Bitkit foregrounded until the payer's request state or logs confirm receipt of the cancellation If the send confirmation is still open, try to confirm payment and verify it is refused - Verify no transaction to the request's receiving address was broadcast and no payment proof was created for the period + Verify no transaction for that period was broadcast to the request's receiving address and no payment proof was created for the period Return to Subscriptions and verify the canceled subscription offers no action to pay its unpaid period + Reset the subscription clock offset in Dev Settings diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index ed95815bb..1ed548340 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -14,12 +14,12 @@ sheets. - Launch the E2E Bitkit app with Paykit UI enabled on the payer instance, with the creator saved as a linked contact and a spendable balance above 5,000 sats + Launch the E2E Bitkit app with Paykit UI enabled on the payer instance, with the creator saved as a linked contact, on-chain savings above 5,000 sats plus the network fee, and no usable Lightning balance, so a first payment is sent on-chain Have the creator instance send the "Journey Sub" proposal as described in create-and-propose.xml Open the drawer menu and tap Subscriptions Verify a row for "Journey Sub" appears under the PROPOSALS section Tap the "Journey Sub" row (id "SubscriptionRow-<paymentRequestId>") - Verify the Review and Subscribe sheet appears with 5,000 sats and the creator contact card + Verify the Review and Subscribe sheet appears with 5,000 sats and the subscription card showing its name and cadence Verify the swipe control reads "Swipe To Subscribe", whether or not the first period is due on acceptance Verify the swipe knob is brand orange, as on the cancel and delete sheets Swipe the confirm control to the end From 570c54e4e8e3e24e9d3b58fe22251837df246785 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Oct 2026 08:27:19 -0300 Subject: [PATCH 10/19] fix: open coin selection for first payment with manual coins Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 61 +++++++++++++------ BitkitTests/SendConfirmationViewTests.swift | 21 +++++++ 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 26383a06e..4a3b226f0 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -773,27 +773,23 @@ struct SendConfirmationView: View { showManualConfirmation() return } + if isAutomatic, Self.needsManualCoinSelection( + walletType: app.selectedWalletToPayFrom, + isHardwarePayment: hwSend.isActive, + coinSelectionMethod: settings.coinSelectionMethod, + selectedUtxos: wallet.selectedUtxos + ) { + showManualConfirmation() + try? await openManualCoinSelection() + return + } try await Self.requireManualCoinSelection( walletType: app.selectedWalletToPayFrom, isHardwarePayment: hwSend.isActive, coinSelectionMethod: settings.coinSelectionMethod, selectedUtxos: wallet.selectedUtxos ) { - guard let context = confirmationContext else { return } - func isCurrentSend() -> Bool { - !Task.isCancelled && confirmationContext == context - } - guard isCurrentSend() else { throw CancellationError() } - do { - try await wallet.loadAvailableUtxos(isCurrentSend: isCurrentSend) - guard isCurrentSend() else { throw CancellationError() } - navigationPath.append(.utxoSelection) - } catch { - guard isCurrentSend(), !(error is CancellationError) else { throw CancellationError() } - Logger.error("Failed to reload UTXOs for manual selection: \(error)") - showManualCoinSelectionError() - throw error - } + try await openManualCoinSelection() } if isFeeRateMissing { try await wallet.setFeeRate(speed: settings.defaultTransactionSpeed) @@ -948,6 +944,24 @@ struct SendConfirmationView: View { isPreparingRequest || hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) } + private func openManualCoinSelection() async throws { + guard let context = confirmationContext else { return } + func isCurrentSend() -> Bool { + !Task.isCancelled && confirmationContext == context + } + guard isCurrentSend() else { throw CancellationError() } + do { + try await wallet.loadAvailableUtxos(isCurrentSend: isCurrentSend) + guard isCurrentSend() else { throw CancellationError() } + navigationPath.append(.utxoSelection) + } catch { + guard isCurrentSend(), !(error is CancellationError) else { throw CancellationError() } + Logger.error("Failed to reload UTXOs for manual selection: \(error)") + showManualCoinSelectionError() + throw error + } + } + private func showManualCoinSelectionError() { app.toast(type: .error, title: t("other__try_again")) } @@ -977,12 +991,25 @@ struct SendConfirmationView: View { selectedUtxos: [SpendableUtxo]?, prepareSelection: () async throws -> Void ) async throws { - guard walletType == .onchain, !isHardwarePayment, coinSelectionMethod == .manual, - selectedUtxos?.isEmpty != false else { return } + guard needsManualCoinSelection( + walletType: walletType, + isHardwarePayment: isHardwarePayment, + coinSelectionMethod: coinSelectionMethod, + selectedUtxos: selectedUtxos + ) else { return } try await prepareSelection() throw CancellationError() } + static func needsManualCoinSelection( + walletType: WalletType, + isHardwarePayment: Bool, + coinSelectionMethod: CoinSelectionMethod, + selectedUtxos: [SpendableUtxo]? + ) -> Bool { + walletType == .onchain && !isHardwarePayment && coinSelectionMethod == .manual && selectedUtxos?.isEmpty != false + } + static func isSwipeDisabled( walletType: WalletType, isHardwarePayment: Bool, diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index a680936d3..84a9a1ad8 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -36,6 +36,27 @@ final class SendConfirmationViewTests: XCTestCase { XCTAssertFalse(SendConfirmationView.automaticPaymentLacksFee(walletType: .lightning, transactionFee: 0)) } + func testManualCoinSelectionIsNeededOnlyForOnchainSavingsWithoutSelectedCoins() { + func needs(_ walletType: WalletType, hardware: Bool = false, method: CoinSelectionMethod = .manual) -> Bool { + SendConfirmationView.needsManualCoinSelection( + walletType: walletType, + isHardwarePayment: hardware, + coinSelectionMethod: method, + selectedUtxos: nil + ) + } + XCTAssertTrue(needs(.onchain)) + XCTAssertFalse(needs(.lightning)) + XCTAssertFalse(needs(.onchain, hardware: true)) + XCTAssertFalse(needs(.onchain, method: .autopilot)) + XCTAssertTrue(SendConfirmationView.needsManualCoinSelection( + walletType: .onchain, + isHardwarePayment: false, + coinSelectionMethod: .manual, + selectedUtxos: [] + )) + } + func testLightningFailureReleasesOnlyDefinitePreSubmissionAttempts() { XCTAssertEqual( SendConfirmationView.privatePaymentListOutcomeForLightningFailure( From 68367432f6710c07fc150404d5894dd67df9e945 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Oct 2026 08:27:28 -0300 Subject: [PATCH 11/19] fix: pick hardware funding after lightning falls back to onchain Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/Send/SendSheet.swift | 55 ++++++++++++++--------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index f20cb85bc..7c522b80c 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -142,6 +142,7 @@ struct SendSheet: View { @State private var hwSend: HwSendCoordinator @State private var setupTask: Task? @State private var isSetupPending = false + @State private var isFallbackFundingPending = false init(config: SendSheetItem, isEmbedded: Bool = false) { self.config = config @@ -244,7 +245,11 @@ struct SendSheet: View { .sheet(isPresented: reconnectPairingBinding) { HardwarePairingSheet(config: HardwarePairingSheetItem()) } - .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut, isEnabled: !isPreparingRequest && !isResumingAcceptedOrdinarySend) + .offlineSheetOverlay( + title: t("wallet__send_bitcoin"), + forceShow: syncTimedOut, + isEnabled: !isPreparingRequest && !isResumingAcceptedOrdinarySend + ) .onChange(of: shouldDismissUnavailableRequest) { _, shouldDismiss in guard shouldDismiss else { return } sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "Incoming payment request is no longer available") @@ -389,29 +394,29 @@ struct SendSheet: View { isSetupPending = false } } - if incomingPaymentRequest == nil, config.hardwareWalletId == nil, - currentRoot == .options || app.selectedWalletToPayFrom == .onchain - { - do { - if let attempt = try await OnchainSendAttemptService.shared.unresolvedAttempt( - walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) - ) { - let context = OnchainSendPendingContext(attemptId: attempt.id, walletId: attempt.walletId, txid: attempt.txid) - if attempt.requestId != nil || attempt.orderId != nil || attempt.status != .accepted { - hasValidatedAfterSync = true - replaceRootRoute(with: .onchainOperationPending(context, requestId: attempt.requestId)) - return - } - isResumingAcceptedOrdinarySend = true + if incomingPaymentRequest == nil, config.hardwareWalletId == nil, + currentRoot == .options || app.selectedWalletToPayFrom == .onchain + { + do { + if let attempt = try await OnchainSendAttemptService.shared.unresolvedAttempt( + walletId: OnchainSendAttemptService.walletId(index: LightningService.shared.currentWalletIndex) + ) { + let context = OnchainSendPendingContext(attemptId: attempt.id, walletId: attempt.walletId, txid: attempt.txid) + if attempt.requestId != nil || attempt.orderId != nil || attempt.status != .accepted { hasValidatedAfterSync = true - app.selectedWalletToPayFrom = .onchain - replaceRootRoute(with: Self.acceptedOrdinaryStartupRoute(attempt: attempt)) + replaceRootRoute(with: .onchainOperationPending(context, requestId: attempt.requestId)) return } - } catch { - Logger.warn("Could not inspect retained on-chain follow-up", context: "SendSheet") + isResumingAcceptedOrdinarySend = true + hasValidatedAfterSync = true + app.selectedWalletToPayFrom = .onchain + replaceRootRoute(with: Self.acceptedOrdinaryStartupRoute(attempt: attempt)) + return } + } catch { + Logger.warn("Could not inspect retained on-chain follow-up", context: "SendSheet") } + } do { try await wallet.loadFeeRateWithRetry(speed: settings.defaultTransactionSpeed) } catch is CancellationError { @@ -642,6 +647,7 @@ struct SendSheet: View { if requestedAmount == nil { navigationPath = [.amount] } + selectHardwareFundingSourceAfterOnchainFallback() hasValidatedAfterSync = true return .ready } else { @@ -705,6 +711,15 @@ struct SendSheet: View { && app.contactPaymentContext?.incomingPaymentRequest?.id == requestId } + private func selectHardwareFundingSourceAfterOnchainFallback() { + guard let request = incomingPaymentRequest else { return } + isFallbackFundingPending = true + Task { + _ = await selectHardwareFundingSourceIfNeeded(amountSats: request.amountSats, requestId: request.id) + isFallbackFundingPending = false + } + } + private func selectHardwareFundingSourceIfNeeded( amountSats: UInt64, requestId: PaykitPaymentRequest.ID @@ -811,7 +826,7 @@ struct SendSheet: View { prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, routingCacheResetAttempted: routingCacheResetAttempted, preparingRequest: isPreparingRequest ? config.preparation?.request : nil, - isSetupPending: isSetupPending + isSetupPending: isSetupPending || isFallbackFundingPending ) case .hardwareSign: let contactContext = app.contactPaymentContext From 6e02959767ccfa91ce3f315f66be10f306b92d56 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Oct 2026 14:03:37 -0300 Subject: [PATCH 12/19] chore: log why first subscription payment needs confirmation Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 4a3b226f0..81c15aa55 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -371,8 +371,7 @@ struct SendConfirmationView: View { guard preparingRequest == nil else { return } ensureSendAmountFromScannedInvoicesIfNeeded() if app.contactPaymentContext?.isInitialSubscriptionPayment == true, !shouldAutomaticallyPay { - requiresPaymentConfirmation = true - showDetails = true + showManualConfirmation(reason: "not automatic when opened") } await calculateTransactionFee() await calculateRoutingFee() @@ -384,8 +383,7 @@ struct SendConfirmationView: View { guard !isPending, preparingRequest == nil else { return } Task { @MainActor in if app.contactPaymentContext?.isInitialSubscriptionPayment == true, !shouldAutomaticallyPay { - requiresPaymentConfirmation = true - showDetails = true + showManualConfirmation(reason: "not automatic after setup") } await startAutomaticPaymentIfNeeded() } @@ -770,7 +768,7 @@ struct SendConfirmationView: View { private func submitPayment(isAutomatic: Bool = false) async throws { guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() } if isAutomatic, isFeeRateMissing { - showManualConfirmation() + showManualConfirmation(reason: "fee rate missing") return } if isAutomatic, Self.needsManualCoinSelection( @@ -779,7 +777,7 @@ struct SendConfirmationView: View { coinSelectionMethod: settings.coinSelectionMethod, selectedUtxos: wallet.selectedUtxos ) { - showManualConfirmation() + showManualConfirmation(reason: "manual coin selection") try? await openManualCoinSelection() return } @@ -798,7 +796,7 @@ struct SendConfirmationView: View { if isAutomatic { await settleTransactionFee() if Self.automaticPaymentLacksFee(walletType: app.selectedWalletToPayFrom, transactionFee: transactionFee) { - showManualConfirmation() + showManualConfirmation(reason: "onchain fee unknown") return } } @@ -852,7 +850,7 @@ struct SendConfirmationView: View { } if requiresManualConfirmation(isAutomatic: isAutomatic) { - showManualConfirmation() + showManualConfirmation(reason: "hardware wallet") return } @@ -1048,7 +1046,8 @@ struct SendConfirmationView: View { ) } - private func showManualConfirmation() { + private func showManualConfirmation(reason: String) { + Logger.info("Opening manual confirmation for first subscription payment: \(reason)") hasStartedAutomaticPayment = false requiresPaymentConfirmation = true showDetails = true @@ -1191,7 +1190,7 @@ struct SendConfirmationView: View { if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } - showManualConfirmation() + showManualConfirmation(reason: "hardware wallet") return } From f907ff9728ca7ced8d3234775ab0202df591ded6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Oct 2026 14:49:30 -0300 Subject: [PATCH 13/19] fix: wait for a swipe after choosing coins for first payment Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 17 ++++++++++++++- BitkitTests/SendConfirmationViewTests.swift | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 81c15aa55..765779fb2 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -222,7 +222,22 @@ struct SendConfirmationView: View { private var shouldAutomaticallyPay: Bool { retainedHardwareRetry == nil && preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true && - !hwSend.isActive && !requiresPaymentConfirmation + !hwSend.isActive && !requiresPaymentConfirmation && !Self.hasManuallyChosenCoins( + walletType: app.selectedWalletToPayFrom, + isHardwarePayment: hwSend.isActive, + coinSelectionMethod: settings.coinSelectionMethod, + selectedUtxos: wallet.selectedUtxos + ) + } + + /// The coin picker opens a new confirmation, which must wait for a swipe instead of paying by itself. + static func hasManuallyChosenCoins( + walletType: WalletType, + isHardwarePayment: Bool, + coinSelectionMethod: CoinSelectionMethod, + selectedUtxos: [SpendableUtxo]? + ) -> Bool { + walletType == .onchain && !isHardwarePayment && coinSelectionMethod == .manual && selectedUtxos?.isEmpty == false } static func acceptedOnchainRoute( diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index 84a9a1ad8..b2045c1c7 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -57,6 +57,27 @@ final class SendConfirmationViewTests: XCTestCase { )) } + func testManuallyChosenCoinsAreDetectedOnlyForOnchainSavingsWithManualSelection() { + func hasChosen( + _ walletType: WalletType = .onchain, hardware: Bool = false, method: CoinSelectionMethod = .manual, + utxos: [SpendableUtxo]? + ) -> Bool { + SendConfirmationView.hasManuallyChosenCoins( + walletType: walletType, + isHardwarePayment: hardware, + coinSelectionMethod: method, + selectedUtxos: utxos + ) + } + let utxo = SpendableUtxo(outpoint: OutPoint(txid: "00", vout: 0), valueSats: 1000) + XCTAssertTrue(hasChosen(utxos: [utxo])) + XCTAssertFalse(hasChosen(utxos: nil)) + XCTAssertFalse(hasChosen(utxos: [])) + XCTAssertFalse(hasChosen(.lightning, utxos: [utxo])) + XCTAssertFalse(hasChosen(hardware: true, utxos: [utxo])) + XCTAssertFalse(hasChosen(method: .autopilot, utxos: [utxo])) + } + func testLightningFailureReleasesOnlyDefinitePreSubmissionAttempts() { XCTAssertEqual( SendConfirmationView.privatePaymentListOutcomeForLightningFailure( From 3cfc918e35a0daf005486ea2dd4db923bc3dde24 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 13 Oct 2026 07:55:08 -0300 Subject: [PATCH 14/19] fix: treat send setup as pending until it has run Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/Send/SendSheet.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 7c522b80c..07ac88f5b 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -141,7 +141,8 @@ struct SendSheet: View { @State private var pinCheckContinuations: [CheckedContinuation] = [] @State private var hwSend: HwSendCoordinator @State private var setupTask: Task? - @State private var isSetupPending = false + /// Starts pending so a confirmation rendered before `setUpSend` runs does not read setup as finished. + @State private var isSetupPending = true @State private var isFallbackFundingPending = false init(config: SendSheetItem, isEmbedded: Bool = false) { From b2ae7e15b96e164e91f1af052f6b913bf9395df9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 13 Oct 2026 07:55:09 -0300 Subject: [PATCH 15/19] fix: keep first payment automatic while the fee rate loads Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/Send/SendConfirmationView.swift | 10 ++++++++++ BitkitTests/SendConfirmationViewTests.swift | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 765779fb2..8dcbab77d 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -783,6 +783,11 @@ struct SendConfirmationView: View { private func submitPayment(isAutomatic: Bool = false) async throws { guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() } if isAutomatic, isFeeRateMissing { + guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else { + // The rate is still loading; the automatic start runs again when send setup finishes. + hasStartedAutomaticPayment = false + return + } showManualConfirmation(reason: "fee rate missing") return } @@ -880,6 +885,11 @@ struct SendConfirmationView: View { isAutomatic && isHardwarePayment } + /// A missing rate that has not failed is still loading, so the automatic payment waits instead of asking for a swipe. + static func automaticPaymentAwaitsFeeRate(isFeeRateMissing: Bool, feeRateLoadFailed: Bool) -> Bool { + isFeeRateMissing && !feeRateLoadFailed + } + /// An automatic on-chain payment must not be sent before its fee is known, because the fee warnings read it. static func automaticPaymentLacksFee(walletType: WalletType, transactionFee: Int) -> Bool { walletType == .onchain && transactionFee <= 0 diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index b2045c1c7..e4dbd4d35 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -30,6 +30,12 @@ final class SendConfirmationViewTests: XCTestCase { } } + func testAutomaticPaymentAwaitsFeeRateOnlyWhileItIsLoading() { + XCTAssertTrue(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: true, feeRateLoadFailed: false)) + XCTAssertFalse(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: true, feeRateLoadFailed: true)) + XCTAssertFalse(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: false, feeRateLoadFailed: false)) + } + func testAutomaticPaymentLacksFeeOnlyForOnchainWithoutCalculatedFee() { XCTAssertTrue(SendConfirmationView.automaticPaymentLacksFee(walletType: .onchain, transactionFee: 0)) XCTAssertFalse(SendConfirmationView.automaticPaymentLacksFee(walletType: .onchain, transactionFee: 143)) From 3758d9c71a18348be5972d01e5fbc66b492b1fcd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Oct 2026 08:48:14 -0300 Subject: [PATCH 16/19] fix: wait for wallet switch before first payment starts Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 19 +++++++++++++++++-- BitkitTests/SendConfirmationViewTests.swift | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 8dcbab77d..00042313a 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -431,6 +431,7 @@ struct SendConfirmationView: View { } if !Task.isCancelled, walletSwitchContext == context { walletSwitchContext = nil + await startAutomaticPaymentIfNeeded() } } .onDisappear { @@ -482,8 +483,12 @@ struct SendConfirmationView: View { @MainActor private func startAutomaticPaymentIfNeeded() async { - guard shouldAutomaticallyPay, !hasStartedAutomaticPayment, !isSetupPending - else { return } + guard Self.canStartAutomaticPayment( + shouldAutomaticallyPay: shouldAutomaticallyPay, + hasStartedAutomaticPayment: hasStartedAutomaticPayment, + isSetupPending: isSetupPending, + isPreparingWallet: walletSwitchContext != nil + ) else { return } hasStartedAutomaticPayment = true do { try await submitPayment(isAutomatic: true) @@ -881,6 +886,16 @@ struct SendConfirmationView: View { } } + /// The wallet switch chooses coins separately from send setup, so the automatic start waits for both. + static func canStartAutomaticPayment( + shouldAutomaticallyPay: Bool, + hasStartedAutomaticPayment: Bool, + isSetupPending: Bool, + isPreparingWallet: Bool + ) -> Bool { + shouldAutomaticallyPay && !hasStartedAutomaticPayment && !isSetupPending && !isPreparingWallet + } + static func requiresManualConfirmation(isAutomatic: Bool, isHardwarePayment: Bool) -> Bool { isAutomatic && isHardwarePayment } diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index e4dbd4d35..dd2412bff 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -30,6 +30,24 @@ final class SendConfirmationViewTests: XCTestCase { } } + func testAutomaticPaymentStartsOnlyAfterSetupAndWalletSwitchFinish() { + XCTAssertTrue(SendConfirmationView.canStartAutomaticPayment( + shouldAutomaticallyPay: true, hasStartedAutomaticPayment: false, isSetupPending: false, isPreparingWallet: false + )) + XCTAssertFalse(SendConfirmationView.canStartAutomaticPayment( + shouldAutomaticallyPay: true, hasStartedAutomaticPayment: false, isSetupPending: false, isPreparingWallet: true + )) + XCTAssertFalse(SendConfirmationView.canStartAutomaticPayment( + shouldAutomaticallyPay: true, hasStartedAutomaticPayment: false, isSetupPending: true, isPreparingWallet: false + )) + XCTAssertFalse(SendConfirmationView.canStartAutomaticPayment( + shouldAutomaticallyPay: true, hasStartedAutomaticPayment: true, isSetupPending: false, isPreparingWallet: false + )) + XCTAssertFalse(SendConfirmationView.canStartAutomaticPayment( + shouldAutomaticallyPay: false, hasStartedAutomaticPayment: false, isSetupPending: false, isPreparingWallet: false + )) + } + func testAutomaticPaymentAwaitsFeeRateOnlyWhileItIsLoading() { XCTAssertTrue(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: true, feeRateLoadFailed: false)) XCTAssertFalse(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: true, feeRateLoadFailed: true)) From 190d434075e9fafb490303a8c01433619536d844 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Oct 2026 08:48:34 -0300 Subject: [PATCH 17/19] fix: reload fee rate when retrying first subscription payment Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/Send/SendSheet.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 07ac88f5b..8c7c78461 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -1220,6 +1220,15 @@ struct SendSheet: View { return } + // `resetSendState` cleared the rate and setup does not run again, so the automatic payment would wait for it forever. + do { + try await wallet.loadFeeRateWithRetry(speed: settings.defaultTransactionSpeed) + } catch is CancellationError { + return + } catch { + Logger.error("Failed to set default fee rate for retry: \(error)") + } + guard sheets.activeSheetConfiguration?.id == .subscription, app.ownsContactPaymentContext(context) else { From 9cd6c3e22892fdf8c77effbc856606ed26b83376 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Oct 2026 09:22:45 -0300 Subject: [PATCH 18/19] fix: start first payment from wallet switch observer Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 6 +- .../SendConfirmationLifecycleTests.swift | 102 +++++++++++++++--- 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 00042313a..3bacea1ea 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -431,9 +431,13 @@ struct SendConfirmationView: View { } if !Task.isCancelled, walletSwitchContext == context { walletSwitchContext = nil - await startAutomaticPaymentIfNeeded() } } + .onChange(of: walletSwitchContext) { previous, context in + // Clearing the context cancels the switch task, so the start runs outside it with the current setup state. + guard previous != nil, context == nil, confirmationContext != nil, preparingRequest == nil else { return } + Task { @MainActor in await startAutomaticPaymentIfNeeded() } + } .onDisappear { if !isEditingSendDetails || sendContext != walletSwitchContext { walletSwitchContext = nil diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index 1bac4e761..844b45499 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -384,27 +384,84 @@ final class SendConfirmationLifecycleTests: XCTestCase { XCTAssertTrue(shownToasts.isEmpty) } + func testInitialPaymentStartsOnceWhenSetupFinishesBeforeWalletSwitch() async throws { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let settings = SettingsViewModel.shared + let pinEnabled = settings.pinEnabled + addTeardownBlock { @MainActor in settings.pinEnabled = pinEnabled } + settings.pinEnabled = true + settings.requirePinForPayments = true + settings.useBiometrics = false + settings.coinSelectionMethod = .autopilot + let wallet = ControlledSendWallet(operation: .selection) + let app = AppViewModel() + let sheets = SheetViewModel() + sheets.activeSheetConfiguration = SheetConfiguration(id: .send, data: nil) + app.contactPaymentContext = ContactPaymentContext(publicKey: "pubkytest", isInitialSubscriptionPayment: true) + app.scannedOnchainInvoice = invoice + app.selectedWalletToPayFrom = .lightning + wallet.sendAmountSats = 6007 + wallet.selectedFeeRateSatsPerVByte = 2 + wallet.totalOnchainSats = 1_000_000 + let setup = SendConfirmationTestSetup() + var path: [SendRoute] = [] + let window = host( + app: app, + wallet: wallet, + sheets: sheets, + path: Binding(get: { path }, set: { path = $0 }), + setup: setup + ) + defer { close(window) } + try await Task.sleep(for: .milliseconds(100)) + + app.selectedWalletToPayFrom = .onchain + await fulfillment(of: [wallet.started], timeout: 3) + setup.isPending = false + try await Task.sleep(for: .milliseconds(100)) + XCTAssertTrue(setup.pinChecks.isEmpty) + + wallet.resume() + await fulfillment(of: [wallet.finished], timeout: 3) + let deadline = ContinuousClock.now + .seconds(3) + while setup.pinChecks.isEmpty, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(setup.pinChecks, [false], "The payment starts once and is not cancelled while it awaits the PIN") + } + private func host( app: AppViewModel, wallet: WalletViewModel, sheets: SheetViewModel, path: Binding<[SendRoute]>, - navigation: SendConfirmationTestNavigation? = nil + navigation: SendConfirmationTestNavigation? = nil, + setup: SendConfirmationTestSetup? = nil ) -> UIWindow { let hwSend = HwSendCoordinator() - let confirmation = SendConfirmationView( - navigationPath: path, - isSubmittingPayment: .constant(false), - hwSend: hwSend, - requestPinCheck: { - XCTFail("Preparing confirmation must not authorize a payment") - return false - }, - prepareIncomingPaymentRequest: { - XCTFail("Preparing confirmation must not submit a payment request") - }, - routingCacheResetAttempted: false - ) + let makeConfirmation = { (isSetupPending: Bool) in + SendConfirmationView( + navigationPath: path, + isSubmittingPayment: .constant(false), + hwSend: hwSend, + requestPinCheck: { + guard let setup else { + XCTFail("Preparing confirmation must not authorize a payment") + return false + } + setup.pinChecks.append(Task.isCancelled) + return false + }, + prepareIncomingPaymentRequest: { + XCTFail("Preparing confirmation must not submit a payment request") + }, + routingCacheResetAttempted: false, + isSetupPending: isSetupPending + ) + } + let confirmation = SetupObservingConfirmation(setup: setup, makeConfirmation: makeConfirmation) let view = Group { if let navigation { @Bindable var navigation = navigation @@ -464,6 +521,23 @@ final class SendConfirmationLifecycleTests: XCTestCase { } } +@Observable +@MainActor +private final class SendConfirmationTestSetup { + var isPending = true + /// Whether the payment task was cancelled at each PIN request. + var pinChecks: [Bool] = [] +} + +private struct SetupObservingConfirmation: View { + let setup: SendConfirmationTestSetup? + let makeConfirmation: (Bool) -> SendConfirmationView + + var body: some View { + makeConfirmation(setup?.isPending ?? false) + } +} + @Observable @MainActor private final class SendConfirmationTestNavigation { From 608930d933584788f662835584e672233d7c482b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Oct 2026 09:22:56 -0300 Subject: [PATCH 19/19] fix: keep stale retry from setting a newer send's fee rate Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/Send/SendSheet.swift | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 8c7c78461..2d09b1ab3 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -1220,13 +1220,21 @@ struct SendSheet: View { return } + /// The retry task outlives the sheet, so it must not set the rate of a send opened after it. + func isCurrentRetry() -> Bool { + sheets.activeSheetConfiguration?.id == .subscription && app.ownsContactPaymentContext(context) + } // `resetSendState` cleared the rate and setup does not run again, so the automatic payment would wait for it forever. - do { - try await wallet.loadFeeRateWithRetry(speed: settings.defaultTransactionSpeed) - } catch is CancellationError { - return - } catch { - Logger.error("Failed to set default fee rate for retry: \(error)") + if isCurrentRetry() { + do { + try await wallet.loadFeeRateWithRetry(speed: settings.defaultTransactionSpeed) { + try await wallet.setFeeRate(speed: $0, isCurrentSend: isCurrentRetry) + } + } catch is CancellationError { + // The ownership check below releases a retry that no longer owns the send. + } catch { + Logger.error("Failed to set default fee rate for retry: \(error)") + } } guard sheets.activeSheetConfiguration?.id == .subscription,