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..3d264a54e 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, 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 @@ -954,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) { @@ -984,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/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() } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index cfdec8cfd..3bacea1ea 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 @@ -219,9 +221,23 @@ struct SendConfirmationView: View { } private var shouldAutomaticallyPay: Bool { - retainedHardwareRetry == nil && preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true && app - .selectedWalletToPayFrom == .lightning && - !hwSend.isActive && !requiresPaymentConfirmation + retainedHardwareRetry == nil && preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true && + !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( @@ -370,8 +386,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() @@ -379,6 +394,15 @@ 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 { + showManualConfirmation(reason: "not automatic after setup") + } + await startAutomaticPaymentIfNeeded() + } + } .onChange(of: wallet.selectedFeeRateSatsPerVByte) { guard preparingRequest == nil else { return } Task { @@ -409,6 +433,11 @@ struct SendConfirmationView: View { walletSwitchContext = nil } } + .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 @@ -458,8 +487,12 @@ struct SendConfirmationView: View { @MainActor private func startAutomaticPaymentIfNeeded() async { - guard shouldAutomaticallyPay, !hasStartedAutomaticPayment - else { return } + guard Self.canStartAutomaticPayment( + shouldAutomaticallyPay: shouldAutomaticallyPay, + hasStartedAutomaticPayment: hasStartedAutomaticPayment, + isSetupPending: isSetupPending, + isPreparingWallet: walletSwitchContext != nil + ) else { return } hasStartedAutomaticPayment = true do { try await submitPayment(isAutomatic: true) @@ -758,32 +791,45 @@ 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 + } + if isAutomatic, Self.needsManualCoinSelection( + walletType: app.selectedWalletToPayFrom, + isHardwarePayment: hwSend.isActive, + coinSelectionMethod: settings.coinSelectionMethod, + selectedUtxos: wallet.selectedUtxos + ) { + showManualConfirmation(reason: "manual coin selection") + 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) } + if isAutomatic { + await settleTransactionFee() + if Self.automaticPaymentLacksFee(walletType: app.selectedWalletToPayFrom, transactionFee: transactionFee) { + showManualConfirmation(reason: "onchain fee unknown") + return + } + } + // Validate payment and show warnings if needed let warnings = await validatePayment() if !warnings.isEmpty { @@ -833,7 +879,7 @@ struct SendConfirmationView: View { } if requiresManualConfirmation(isAutomatic: isAutomatic) { - showManualConfirmation() + showManualConfirmation(reason: "hardware wallet") return } @@ -844,8 +890,28 @@ struct SendConfirmationView: View { } } - static func requiresManualConfirmation(isAutomatic: Bool, walletType: WalletType, isHardwarePayment: Bool) -> Bool { - isAutomatic && (walletType != .lightning || isHardwarePayment) + /// 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 + } + + /// 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 } static func privatePaymentListOutcomeForLightningFailure( @@ -920,6 +986,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")) } @@ -949,12 +1033,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, @@ -989,12 +1086,12 @@ struct SendConfirmationView: View { private func requiresManualConfirmation(isAutomatic: Bool) -> Bool { Self.requiresManualConfirmation( isAutomatic: isAutomatic, - walletType: app.selectedWalletToPayFrom, isHardwarePayment: hwSend.isActive ) } - private func showManualConfirmation() { + private func showManualConfirmation(reason: String) { + Logger.info("Opening manual confirmation for first subscription payment: \(reason)") hasStartedAutomaticPayment = false requiresPaymentConfirmation = true showDetails = true @@ -1137,7 +1234,7 @@ struct SendConfirmationView: View { if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } - showManualConfirmation() + showManualConfirmation(reason: "hardware wallet") return } @@ -1688,6 +1785,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..2d09b1ab3 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -141,6 +141,9 @@ struct SendSheet: View { @State private var pinCheckContinuations: [CheckedContinuation] = [] @State private var hwSend: HwSendCoordinator @State private var setupTask: Task? + /// 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) { self.config = config @@ -243,7 +246,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") @@ -381,30 +388,36 @@ struct SendSheet: View { app.resetSendState() } setupTask?.cancel() + isSetupPending = true setupTask = Task { - 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 + defer { + if !Task.isCancelled { + 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 - 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 { @@ -635,6 +648,7 @@ struct SendSheet: View { if requestedAmount == nil { navigationPath = [.amount] } + selectHardwareFundingSourceAfterOnchainFallback() hasValidatedAfterSync = true return .ready } else { @@ -698,6 +712,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 @@ -803,7 +826,8 @@ struct SendSheet: View { requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, routingCacheResetAttempted: routingCacheResetAttempted, - preparingRequest: isPreparingRequest ? config.preparation?.request : nil + preparingRequest: isPreparingRequest ? config.preparation?.request : nil, + isSetupPending: isSetupPending || isFallbackFundingPending ) case .hardwareSign: let contactContext = app.contactPaymentContext @@ -1196,6 +1220,23 @@ 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. + 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, app.ownsContactPaymentContext(context) else { 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 { diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift index 54876bdab..dd2412bff 100644 --- a/BitkitTests/SendConfirmationViewTests.swift +++ b/BitkitTests/SendConfirmationViewTests.swift @@ -14,26 +14,94 @@ 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 + )) } } + 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)) + XCTAssertFalse(SendConfirmationView.automaticPaymentAwaitsFeeRate(isFeeRateMissing: false, feeRateLoadFailed: false)) + } + + func testAutomaticPaymentLacksFeeOnlyForOnchainWithoutCalculatedFee() { + XCTAssertTrue(SendConfirmationView.automaticPaymentLacksFee(walletType: .onchain, transactionFee: 0)) + XCTAssertFalse(SendConfirmationView.automaticPaymentLacksFee(walletType: .onchain, transactionFee: 143)) + 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 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( diff --git a/changelog.d/next/881.changed.md b/changelog.d/next/881.changed.md new file mode 100644 index 000000000..a227b3d20 --- /dev/null +++ b/changelog.d/next/881.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. 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 05caab154..1ed548340 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -2,29 +2,28 @@ 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. + 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. - 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 swipe control reads "Swipe To Subscribe & Pay" when the first period is due on acceptance, otherwise "Swipe To Subscribe" + 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 - 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