diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 327f85c10..5951092f0 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -513,7 +513,7 @@ 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */, 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */, 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */, - 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */, + 182817BF2F59A7F10055A441 /* XCLocalSwiftPackageReference "../paykit-rs" */, ); productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */; projectDirPath = ""; @@ -1172,15 +1172,14 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pubky/paykit-rs"; - requirement = { - kind = exactVersion; - version = "0.1.0-rc55"; - }; +/* Begin XCLocalSwiftPackageReference section */ + 182817BF2F59A7F10055A441 /* XCLocalSwiftPackageReference "../paykit-rs" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../paykit-rs"; }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/vss-rust-client-ffi"; @@ -1226,7 +1225,7 @@ /* Begin XCSwiftPackageProductDependency section */ 182817C02F59A7F10055A441 /* Paykit */ = { isa = XCSwiftPackageProductDependency; - package = 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */; + package = 182817BF2F59A7F10055A441 /* XCLocalSwiftPackageReference "../paykit-rs" */; productName = Paykit; }; 18D65DFF2EB964B500252335 /* VssRustClientFfi */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 81b2ef402..9a0e5922a 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -28,15 +28,6 @@ "version" : "4.6.1" } }, - { - "identity" : "paykit-rs", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pubky/paykit-rs", - "state" : { - "revision" : "4613beee25d1680ed8bc849446498e9bfb2cdb16", - "version" : "0.1.0-rc55" - } - }, { "identity" : "swift-secp256k1", "kind" : "remoteSourceControl", diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 47d3b8b23..f745e2225 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -209,6 +209,7 @@ struct AppScene: View { @State private var hwWalletManager: HwWalletManager @State private var calculatorInputManager = CalculatorInputManager() @State private var paykitPaymentRequestManager = PaykitPaymentRequestManager() + @State private var paykitAllowanceManager = PaykitAllowanceManager() @State private var initialPaykitSyncGeneration = 0 @State private var hideSplash = false @@ -380,6 +381,7 @@ struct AppScene: View { .environment(hwWalletManager) .environment(calculatorInputManager) .environment(paykitPaymentRequestManager) + .environment(paykitAllowanceManager) } private var appEventContent: some View { @@ -388,6 +390,7 @@ struct AppScene: View { if authState == .authenticated, let pk = pubkyProfile.publicKey { paykitPaymentRequestManager.activate(identity: pk) Task { + await paykitAllowanceManager.activate(identity: pk) try? await contactsManager.loadContacts(for: pk) await refreshPrivateOnlyPaykitReceiverMarker() await refreshIncomingPaykitPaymentRequests(presentItems: false) @@ -402,6 +405,7 @@ struct AppScene: View { } else if authState == .idle { contactsManager.reset() paykitPaymentRequestManager.clear() + paykitAllowanceManager.deactivate() } } .onReceive(contactsManager.$contacts) { contacts in @@ -427,6 +431,9 @@ struct AppScene: View { .onReceive(PaykitPaymentProofService.proofStateChangedPublisher) { Task { await refreshIncomingPaykitPaymentRequests() } } + .onReceive(PaykitAllowanceExecutor.eventPublisher.receive(on: DispatchQueue.main)) { event in + handlePaykitAllowanceEvent(event) + } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in Task { await associateResolvedPaykitOnchainPayment(resolution) } } @@ -992,6 +999,10 @@ struct AppScene: View { await PaykitPaymentProofService.shared.reconcile() } await paykitPaymentRequestManager.refresh() + await paykitAllowanceManager.refresh() + if await paykitAllowanceManager.processIncomingRequests(paykitPaymentRequestManager.pendingRequests) { + await paykitPaymentRequestManager.refresh() + } if presentItems { await presentNextIncomingPaykitItem() } @@ -1085,6 +1096,7 @@ struct AppScene: View { guard sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } for request in requests { guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + if await paykitAllowanceManager.isAutomaticallyHandling(request) { continue } do { let result = try await PrivatePaykitService.shared.beginPaymentRequest(request) guard paykitPaymentRequestManager.isCurrentPresentation(request), @@ -1300,6 +1312,44 @@ struct AppScene: View { await presentNextIncomingPaykitPaymentRequest() } + private func handlePaykitAllowanceEvent(_ event: PaykitAllowanceEvent) { + switch event { + case let .paidAutomatically(counterparty, amountSats, paymentId): + let title = t("subscriptions__allowance_executed_title") + let description = t( + "subscriptions__allowance_executed_description", + variables: ["amount": AllowanceAmountText.fiat(sats: amountSats, currency: currency), "name": contactName(counterparty)] + ) + PaykitAllowanceNotifier.post(title: title, body: description, fallback: { + app.toast(type: .lightning, title: title, description: description, accessibilityIdentifier: "AllowancePaidToast") + }) + Task { + await paykitAllowanceManager.refresh() + do { + try await activity.setContact(counterparty, forPaymentId: paymentId) + } catch { + Logger.warn("Failed to set contact for an automatic allowance payment: \(error)", context: "AppScene") + } + } + case let .limitReached(counterparty, amountSats): + let title = t("subscriptions__allowance_limit_title") + let description = t( + "subscriptions__allowance_limit_description", + variables: ["amount": AllowanceAmountText.fiat(sats: amountSats, currency: currency), "name": contactName(counterparty)] + ) + PaykitAllowanceNotifier.post(title: title, body: description, fallback: { + app.toast(type: .warning, title: title, description: description, accessibilityIdentifier: "AllowanceLimitToast") + }) + case .ledgerChanged: + Task { await paykitAllowanceManager.refresh() } + } + } + + private func contactName(_ publicKey: String) -> String { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }?.displayName + ?? PubkyPublicKeyFormat.displayTruncated(publicKey) + } + private func presentNextIncomingPaykitItem() async { guard sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet else { return } if PaykitSubscriptionNotificationTargetStore.load() != nil { @@ -1313,6 +1363,10 @@ struct AppScene: View { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) return } + if let allowance = paykitAllowanceManager.proposalForPresentation() { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .allowanceReview(allowance))) + return + } await presentNextIncomingPaykitPaymentRequest() } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index da850afd4..b485608de 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1749,3 +1749,50 @@ "settings__adv__pp_both" = "Both"; "settings__adv__pp_lightning_short" = "Lightning"; "settings__adv__pp_onchain_short" = "On-chain"; +"subscriptions__allowances" = "Allowances"; +"subscriptions__allowances_empty_headline" = "Set up\nallowances"; +"subscriptions__allowances_empty_description" = "You can set spending limits to automatically fulfill payment requests from trusted peers."; +"subscriptions__allowance_add" = "Add Allowance"; +"subscriptions__allowance_title" = "Allowance"; +"subscriptions__allowance_choose_contact" = "Choose Contact"; +"subscriptions__allowance_no_contacts" = "Add a contact first. Allowances cover payment requests from your contacts."; +"subscriptions__allowance_set_title" = "Set Allowance"; +"subscriptions__allowance_set_explanation" = "Automatically approve payment requests from {name} below these limits:"; +"subscriptions__allowance_payment_limit" = "Payment limit"; +"subscriptions__allowance_monthly_allowance" = "Monthly allowance"; +"subscriptions__allowance_set_summary" = "Requests up to {perPayment} will be paid automatically, up to {monthly} a month, without asking you each time."; +"subscriptions__allowance_save" = "Save Allowance"; +"subscriptions__allowance_monthly_limit" = "Monthly limit"; +"subscriptions__allowance_per_payment_short" = "{amount} a payment"; +"subscriptions__allowance_per_payment" = "Per payment"; +"subscriptions__allowance_each_month" = "Each month"; +"subscriptions__allowance_up_to" = "Up to {amount}"; +"subscriptions__allowance_status_active" = "Active"; +"subscriptions__allowance_status_waiting" = "Waiting for an answer"; +"subscriptions__allowance_status_needs_answer" = "Waiting for your answer"; +"subscriptions__allowance_status_scheduled" = "Not active yet"; +"subscriptions__allowance_status_expired" = "Expired"; +"subscriptions__allowance_status_declined" = "Declined"; +"subscriptions__allowance_status_conflicted" = "Needs attention"; +"subscriptions__allowance_status_ended" = "Ended"; +"subscriptions__allowance_paid_automatically" = "{amount} paid automatically"; +"subscriptions__allowance_paid_so_far" = "Paid automatically"; +"subscriptions__allowance_offer_headline" = "{name} offers\nan allowance"; +"subscriptions__allowance_request_headline" = "{name} asks for\nan allowance"; +"subscriptions__allowance_offer_explanation" = "{name}'s wallet will pay your requests within these limits without asking each time. Either of you can end it."; +"subscriptions__allowance_request_explanation" = "Your wallet will pay {name}'s requests within these limits without asking you each time. Either of you can end it."; +"subscriptions__allowance_accept" = "Accept"; +"subscriptions__allowance_decline" = "Decline"; +"subscriptions__allowance_swipe_end" = "Swipe To End Allowance"; +"subscriptions__allowance_swipe_withdraw" = "Swipe To Withdraw"; +"subscriptions__allowance_detail_active_allower" = "Requests within these limits are paid automatically. Anything above them asks you first."; +"subscriptions__allowance_detail_active_allowee" = "Your requests within these limits are paid automatically."; +"subscriptions__allowance_detail_ended" = "This allowance has ended. Every request asks again."; +"subscriptions__allowance_error_not_linked" = "This contact is not connected yet. Try again in a moment."; +"subscriptions__allowance_error_unavailable" = "This allowance is not available right now."; +"subscriptions__allowance_payment_in_progress" = "This request is already being paid."; +"subscriptions__allowance_executed_title" = "Payment Executed"; +"subscriptions__allowance_executed_description" = "Auto-pay sent {amount} to {name}"; +"subscriptions__allowance_limit_title" = "Limit Reached"; +"subscriptions__allowance_limit_description" = "A {amount} request from {name} is above your allowance. Review it to pay."; +"subscriptions__allowance_auto_paid" = "Auto-paid"; diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift new file mode 100644 index 000000000..e34670d12 --- /dev/null +++ b/Bitkit/Services/PaykitAllowance.swift @@ -0,0 +1,291 @@ +import Foundation +import Paykit + +/// An Allowance between this wallet and one contact link, built from the SDK record. +/// Eligibility always runs on real time: allowance code never reads `DemoClock`. +struct PaykitAllowance: Identifiable, Hashable { + struct ID: Codable, Hashable { + let counterparty: String + let counterpartyReceiverPath: String + let allowanceId: String + } + + enum Role: String, Codable, Hashable { + case allower + case allowee + } + + enum Status: Hashable { + /// Sent by this wallet; the other side has not answered yet. + case awaitingAnswer + /// Received; this wallet must accept or decline. + case awaitingMyAnswer + case active + case notYetActive + case expired + case declined + case ended + case conflicted + } + + let id: ID + let role: Role + let lifecycleState: Paykit.AllowanceLifecycleState + let isProposedByMe: Bool + let perPaymentMaxSats: UInt64? + let monthlyLimitSats: UInt64? + let monthlyAnchor: Date? + let activeFrom: Date? + let expiresAt: Date? + let allowedPaymentEndpointIdentifiers: [String]? + let lastEventAt: Date? + + var counterparty: String { id.counterparty } + var counterpartyReceiverPath: String { id.counterpartyReceiverPath } + var allowanceId: String { id.allowanceId } + + init?(record: Paykit.AllowanceRecord) { + guard let localRole = record.localRole, + let role = Role(localRole), + let terms = record.terms, + terms.asset() == PaykitIssuerInterop.bitcoinAsset + else { return nil } + + let monthly = terms.periodLimits().first { Self.isMonthly($0.period()) } + id = ID( + counterparty: record.counterparty, + counterpartyReceiverPath: record.counterpartyReceiverPath, + allowanceId: record.allowanceId + ) + self.role = role + lifecycleState = record.state + isProposedByMe = record.proposalOutboundMessageId != nil + perPaymentMaxSats = terms.perPaymentAmount().flatMap { Self.sats(fromBitcoinAmount: $0.maximum()) } + monthlyLimitSats = monthly?.amountLimit().flatMap { Self.sats(fromBitcoinAmount: $0) } + monthlyAnchor = monthly?.period().anchor().flatMap(PaykitAllowanceTime.parse) + activeFrom = terms.activeFrom().flatMap(PaykitAllowanceTime.parse) + expiresAt = terms.expiresAt().flatMap(PaykitAllowanceTime.parse) + allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers() + lastEventAt = record.lastEventAt.flatMap(PaykitAllowanceTime.parse) + } + + init( + id: ID, + role: Role, + lifecycleState: Paykit.AllowanceLifecycleState, + isProposedByMe: Bool, + perPaymentMaxSats: UInt64?, + monthlyLimitSats: UInt64?, + monthlyAnchor: Date?, + activeFrom: Date? = nil, + expiresAt: Date? = nil, + allowedPaymentEndpointIdentifiers: [String]? = nil, + lastEventAt: Date? = nil + ) { + self.id = id + self.role = role + self.lifecycleState = lifecycleState + self.isProposedByMe = isProposedByMe + self.perPaymentMaxSats = perPaymentMaxSats + self.monthlyLimitSats = monthlyLimitSats + self.monthlyAnchor = monthlyAnchor + self.activeFrom = activeFrom + self.expiresAt = expiresAt + self.allowedPaymentEndpointIdentifiers = allowedPaymentEndpointIdentifiers + self.lastEventAt = lastEventAt + } + + func status(at now: Date) -> Status { + switch lifecycleState { + case .proposed: + return isProposedByMe ? .awaitingAnswer : .awaitingMyAnswer + case .accepted: + if let expiresAt, now >= expiresAt { return .expired } + if let activeFrom, now < activeFrom { return .notYetActive } + return .active + case .rejected: + return .declined + case .ended: + return .ended + case .conflicted, .unknown: + return .conflicted + } + } + + /// The payer side: this wallet pays the counterparty's requests automatically. + var isAllower: Bool { role == .allower } + + var canEnd: Bool { + switch lifecycleState { + case .accepted: true + case .proposed: isProposedByMe + default: false + } + } + + var isAnswerable: Bool { + lifecycleState == .proposed && !isProposedByMe + } + + static func isMonthly(_ period: Paykit.AllowancePeriod) -> Bool { + period.kind() == "anchored" && period.every() == 1 && period.unit() == "month" + } + + static func sats(fromBitcoinAmount amount: String) -> UInt64? { + if amount.split(separator: ".").allSatisfy({ $0.allSatisfy { $0 == "0" } }) { + return 0 + } + return PaykitPaymentRequest.sats(fromBitcoinAmount: amount) + } +} + +extension Paykit.AllowanceLifecycleState { + var rawDescription: String { + switch self { + case .proposed: "proposed" + case .accepted: "accepted" + case .rejected: "rejected" + case .ended: "ended" + case .conflicted: "conflicted" + case .unknown: "unknown" + } + } +} + +extension PaykitAllowance.Role { + init?(_ role: Paykit.AllowanceLocalRole) { + switch role { + case .allower: self = .allower + case .allowee: self = .allowee + case .unknown: return nil + } + } +} + +/// Limits picked in USD on the Set Allowance sheet, converted once to whole-sat BTC terms. +struct PaykitAllowanceLimits: Codable, Hashable { + let perPaymentUsd: Decimal + let monthlyUsd: Decimal + let perPaymentSats: UInt64 + let monthlySats: UInt64 + + static let perPaymentStopsUsd: [Decimal] = [1, 5, 10, 20, 50] + static let monthlyStopsUsd: [Decimal] = [10, 50, 100, 200, 500] + + /// Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays. + func terms(monthAnchor: Date, allowedPaymentEndpointIdentifiers: [String]) throws -> Paykit.AllowanceTerms { + let perPayment = try Paykit.AllowanceAmountRange( + minimum: "0", + maximum: WalletViewModel.formatBitcoinAmount(sats: perPaymentSats) + ) + let month = try Paykit.AllowancePeriod( + kind: "anchored", + every: 1, + unit: "month", + anchor: PaykitAllowanceTime.format(monthAnchor) + ) + let monthly = try Paykit.AllowancePeriodLimit( + amountLimit: WalletViewModel.formatBitcoinAmount(sats: monthlySats), + paymentCountLimit: nil, + period: month + ) + return try Paykit.AllowanceTerms( + asset: PaykitIssuerInterop.bitcoinAsset, + perPaymentAmount: perPayment, + periodLimits: [monthly], + lifetimeAmountLimit: nil, + activeFrom: nil, + expiresAt: nil, + allowedPaymentEndpointIdentifiers: allowedPaymentEndpointIdentifiers + ) + } +} + +enum PaykitAllowanceTime { + static func format(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.string(from: date) + } + + static func parse(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } + + /// First instant of the UTC calendar month that contains `date`. + static func monthStart(containing date: Date) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let components = calendar.dateComponents([.year, .month], from: date) + return calendar.date(from: components) ?? date + } + + /// The anchored monthly window `[start, end)` that contains `date`. Anchors on a day that a short month lacks + /// clamp to that month's last day, as the spec's anchored-period arithmetic does. + static func monthlyWindow(anchor: Date, containing date: Date) -> (start: Date, end: Date) { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let boundary: (Int) -> Date = { calendar.date(byAdding: .month, value: $0, to: anchor) ?? anchor } + let anchorMonth = calendar.dateComponents([.year, .month], from: anchor) + let dateMonth = calendar.dateComponents([.year, .month], from: date) + // Every boundary counts from the original anchor, so a clamped February never shortens later months. + var index = ((dateMonth.year ?? 0) - (anchorMonth.year ?? 0)) * 12 + (dateMonth.month ?? 0) - (anchorMonth.month ?? 0) + while boundary(index) > date { + index -= 1 + } + while boundary(index + 1) <= date { + index += 1 + } + return (boundary(index), boundary(index + 1)) + } +} + +/// Wallet-side capacity preflight. The SDK checks capacity only after automatic Acceptance, so Bitkit sums this +/// Allowance's live automatic attempts in the current month first and keeps an over-cap request on the manual flow. +enum PaykitAllowanceCapacity { + struct Attempt: Equatable { + let allowanceId: String + let amountSats: UInt64 + let admittedAt: Date + let isLive: Bool + } + + static func usedSats(allowanceId: String, attempts: [Attempt], anchor: Date, now: Date) -> UInt64 { + let window = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: now) + return attempts + .filter { $0.allowanceId == allowanceId && $0.isLive && $0.admittedAt >= window.start && $0.admittedAt < window.end } + .reduce(0) { $0 + $1.amountSats } + } + + static func fits(amountSats: UInt64, allowance: PaykitAllowance, attempts: [Attempt], now: Date) -> Bool { + if let perPaymentMaxSats = allowance.perPaymentMaxSats, amountSats > perPaymentMaxSats { + return false + } + guard let monthlyLimitSats = allowance.monthlyLimitSats, let anchor = allowance.monthlyAnchor else { + return true + } + let used = usedSats(allowanceId: allowance.allowanceId, attempts: attempts, anchor: anchor, now: now) + return used + amountSats <= monthlyLimitSats + } + + static func attempts(from history: Paykit.AllowanceAccountingHistory) -> [Attempt] { + history.occurrences.flatMap(\.attempts).compactMap { attempt in + guard attempt.mode == .automatic, + let allowanceId = attempt.allowanceId, + let admittedAt = PaykitAllowanceTime.parse(attempt.admittedAt), + let amountSats = PaykitAllowance.sats(fromBitcoinAmount: attempt.amount.value()) + else { return nil } + return Attempt( + allowanceId: allowanceId, + amountSats: amountSats, + admittedAt: admittedAt, + isLive: attempt.status != .failed + ) + } + } +} diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift new file mode 100644 index 000000000..8a302931e --- /dev/null +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -0,0 +1,749 @@ +import Combine +import Foundation +import LDKNode +import Paykit + +protocol PaykitAllowanceSdkHandling: Sendable { + func linkedPeers() async throws -> [LinkedPeerRecord] + func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] + func proposeAllowance( + counterparty: String, + counterpartyReceiverPath: String, + localRole: Paykit.AllowanceLocalRole, + terms: Paykit.AllowanceTerms + ) async throws -> Paykit.AllowanceRecord + func acceptAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord + func rejectAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord + func endAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord + @discardableResult + func receivePrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.PrivateStreamIntakeReport + @discardableResult + func processOutboundPrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.OutboundPrivateSendReport + func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? + func reconcileAllowanceAccounting(_ reconciliation: Paykit.AllowanceAccountingReconciliation) async throws -> Paykit.AllowanceAccountingState + func evaluateAllowanceCandidates(scope: Paykit.PaymentRequestScope, trustedTime: String) async throws -> [Paykit.AllowanceCandidate] + func acceptPaymentRequestAutomatically( + scope: Paykit.PaymentRequestScope, + selection: Paykit.AllowanceSelectionInput, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.AllowanceAssociationRecord + func reserveAutomaticPayment( + occurrence: Paykit.PaymentOccurrence, + expectedAssociationRevision: UInt64, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.PaymentAttemptDecision + func reserveManualPayment(occurrence: Paykit.PaymentOccurrence, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision + func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision + @discardableResult + func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord + @discardableResult + func markPaymentManualOnly(occurrence: Paykit.PaymentOccurrence) async throws -> Paykit.PaymentOccurrenceRecord +} + +extension PaykitSdkService: PaykitAllowanceSdkHandling {} + +/// Local Allowance state kept per identity: USD labels, the grouping of one grant across a contact's links, +/// and the execution journal that restart recovery reads. The SDK ledger stays authoritative for admission. +struct PaykitAllowanceLocalState: Codable, Equatable { + struct Group: Codable, Equatable { + let id: String + let counterparty: String + let limits: PaykitAllowanceLimits + var allowanceIds: [String] + let createdAt: Date + } + + enum Stage: String, Codable { + case prepared + case submitted + case sending + case sent + case succeeded + case failed + case unknown + } + + struct JournalEntry: Codable, Equatable { + let attemptId: String + let isAutomatic: Bool + let requestId: PaykitPaymentRequest.ID + let allowanceId: String? + let amountSats: UInt64 + let paymentEndpointIdentifier: String + var paymentHash: String? + var onchainAddress: String? + var transactionId: String? + var stage: Stage + let createdAt: Date + } + + var groups: [Group] = [] + var journal: [JournalEntry] = [] + var presentedProposalIds: Set = [] + var notifiedRequestIds: Set = [] + var lastTrustedTime: Date? + + func group(containing allowanceId: String) -> Group? { + groups.first { $0.allowanceIds.contains(allowanceId) } + } +} + +protocol PaykitAllowanceStoring: Sendable { + func load(identity: String) throws -> PaykitAllowanceLocalState + func save(_ state: PaykitAllowanceLocalState, identity: String) throws +} + +struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring { + private typealias Stored = [String: PaykitAllowanceLocalState] + + func load(identity: String) throws -> PaykitAllowanceLocalState { + try loadAll()[identity] ?? PaykitAllowanceLocalState() + } + + func save(_ state: PaykitAllowanceLocalState, identity: String) throws { + var all = try loadAll() + all[identity] = state + try Keychain.upsert(key: .paykitAllowanceState, data: JSONEncoder().encode(all)) + } + + private func loadAll() throws -> Stored { + guard let data = try Keychain.load(key: .paykitAllowanceState) else { return [:] } + do { + return try JSONDecoder().decode(Stored.self, from: data) + } catch { + Logger.warn("Discarding invalid Paykit allowance state: \(error)", context: "PaykitAllowance") + return [:] + } + } +} + +/// The side effects of paying one request, behind a protocol so the admission logic is testable without a node. +protocol PaykitAllowancePaying: Sendable { + func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? + func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext) async throws + func prepareProof(_ request: PaykitPaymentRequest, paymentEndpointIdentifier: String, allowanceId: String?) async throws + func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws + func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String) async throws + func payLightning(bolt11: String, sats: UInt64?) async throws + func payOnchain(address: String, sats: UInt64) async throws -> String + func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String) async + func failLightningPayment(paymentHash: String) async + func cancelProofPreparation(_ request: PaykitPaymentRequest) async +} + +struct PaykitAllowanceLivePayer: PaykitAllowancePaying { + func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? { + try await PrivatePaykitService.shared.resolveAllowancePayment(request, eligibleIdentifiers: eligibleIdentifiers) + } + + func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext) async throws { + try await PrivatePaykitService.shared.consumePrivatePaymentList(publicKey: publicKey, context: context) + } + + func prepareProof(_ request: PaykitPaymentRequest, paymentEndpointIdentifier: String, allowanceId: String?) async throws { + guard let kind = PaykitPaymentProofKind(paymentEndpointIdentifier: paymentEndpointIdentifier) else { + throw PaykitPaymentRequestError.requestUnavailable + } + try await PaykitPaymentProofService.shared.prepare( + request: request, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: kind, + allowanceId: allowanceId + ) + } + + func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + try await PaykitPaymentProofService.shared.associateLightningPayment(request, paymentHash: paymentHash) + } + + func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String) async throws { + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted(request, address: address) + } + + func payLightning(bolt11: String, sats: UInt64?) async throws { + _ = try await LightningService.shared.send(bolt11: bolt11, sats: sats) + } + + func payOnchain(address: String, sats: UInt64) async throws -> String { + let feeRate = await (try? CoreService.shared.blocktank.fees(refresh: false))?.mid ?? 2 + return try await String(describing: LightningService.shared.send(address: address, sats: sats, satsPerVbyte: max(feeRate, 1))) + } + + func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String) async { + await PaykitPaymentProofService.shared.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: paymentEndpointIdentifier) + } + + func failLightningPayment(paymentHash: String) async { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) + } + + func cancelProofPreparation(_ request: PaykitPaymentRequest) async { + await PaykitPaymentProofService.shared.cancelPreparation(request) + } +} + +enum PaykitAllowanceEvent: Equatable { + case paidAutomatically(counterparty: String, amountSats: UInt64, paymentId: String) + case limitReached(counterparty: String, amountSats: UInt64) + case ledgerChanged +} + +enum PaykitAllowanceAutoPayResult: Equatable { + /// No accepted Allowance covers the request's link. + case notCovered + /// An Allowance exists but this request stays on the manual flow (over a limit, ended, no payable endpoint). + case manual + /// The payment was handed to the node; the outcome arrives through the payment events. + case started + case completed + /// The payee has not published a payment list newer than the one last paid; the next refresh tries again. + case deferred +} + +enum PaykitAllowanceManualPaymentError: LocalizedError { + case alreadyRecorded + + var errorDescription: String? { + t("subscriptions__allowance_payment_in_progress") + } +} + +/// Runs Allowance admission for incoming requests through the SDK: evaluate, capacity preflight, automatic +/// Acceptance, reserve, begin, pay, record the outcome. The wallet journals every attempt before handoff so a +/// restart resolves it from the node instead of paying again. +actor PaykitAllowanceExecutor { + static let shared = PaykitAllowanceExecutor() + + private static let eventSubject = PassthroughSubject() + + nonisolated static var eventPublisher: AnyPublisher { + eventSubject.eraseToAnyPublisher() + } + + private let sdk: any PaykitAllowanceSdkHandling + private let store: any PaykitAllowanceStoring + private let payer: any PaykitAllowancePaying + private let lightningLookup: any PaykitLightningPaymentProofLookingUp + private let now: @Sendable () -> Date + private var inFlightRequestIds = Set() + private(set) var activeIdentity: String? + + init( + sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared, + store: any PaykitAllowanceStoring = PaykitAllowanceKeychainStore(), + payer: any PaykitAllowancePaying = PaykitAllowanceLivePayer(), + lightningLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.sdk = sdk + self.store = store + self.payer = payer + self.lightningLookup = lightningLookup + self.now = now + } + + func activate(identity: String?) { + activeIdentity = identity + } + + // MARK: Local state + + func localState(identity: String) -> PaykitAllowanceLocalState { + (try? store.load(identity: identity)) ?? PaykitAllowanceLocalState() + } + + func updateLocalState(identity: String, _ change: (inout PaykitAllowanceLocalState) -> Void) { + var state = localState(identity: identity) + change(&state) + do { + try store.save(state, identity: identity) + } catch { + Logger.warn("Failed to save Paykit allowance state: \(error)", context: "PaykitAllowance") + } + } + + func isHandling(_ requestId: PaykitPaymentRequest.ID) -> Bool { + inFlightRequestIds.contains(requestId) + } + + /// Trusted time for eligibility: the real clock, never earlier than the last value given to the SDK, + /// because the SDK refuses a watermark that moves backwards. + func trustedTime(identity: String) -> String { + var date = now() + updateLocalState(identity: identity) { state in + if let last = state.lastTrustedTime, last > date { + date = last + } + state.lastTrustedTime = date + } + return PaykitAllowanceTime.format(date) + } + + // MARK: Ledger + + /// Brings the SDK ledger to a reconciled state. A wallet with no ledger attests an empty history: it has never + /// paid through an Allowance. After a restore, outcomes come from the journal and the node. + @discardableResult + func ensureReconciled(identity: String) async throws -> Paykit.AllowanceAccountingState { + let current = try await sdk.allowanceAccountingState() + if let current, !current.requiresReconciliation { + return current + } + + let history = current?.history ?? Paykit.AllowanceAccountingHistory(associations: [], occurrences: [], watermarks: []) + var outcomes: [Paykit.PaymentOutcomeReport] = [] + for attempt in history.occurrences.flatMap(\.attempts) { + if let outcome = await verifiedOutcome(for: attempt, identity: identity) { + outcomes.append(Paykit.PaymentOutcomeReport(attemptId: attempt.attemptId, outcome: outcome)) + } + } + let reconciled = try await sdk.reconcileAllowanceAccounting( + Paykit.AllowanceAccountingReconciliation( + expectedRevision: current?.revision, + history: history, + outcomes: outcomes, + trustedTime: trustedTime(identity: identity) + ) + ) + Logger.info("Reconciled Paykit allowance accounting with \(outcomes.count) verified outcomes", context: "PaykitAllowance") + return reconciled + } + + /// Resolves attempts a crash or kill left open. Prepared attempts never got a handoff and are released; + /// submitted ones are settled from the node. Nothing is paid again. + func recover(identity: String) async { + do { + // No ledger means nothing was ever admitted. Creating one here would also end the rc55 storage layout. + guard try await sdk.allowanceAccountingState() != nil else { return } + let state = try await ensureReconciled(identity: identity) + for attempt in state.history.occurrences.flatMap(\.attempts) { + switch attempt.status { + case .prepared: + guard attempt.epoch == state.epoch else { continue } + try await record(attemptId: attempt.attemptId, outcome: .failed, identity: identity) + case .submitted, .unknown: + guard let outcome = await verifiedOutcome(for: attempt, identity: identity) else { + if attempt.status == .submitted { + try await record(attemptId: attempt.attemptId, outcome: .unknown, identity: identity) + } + continue + } + try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) + case .succeeded, .failed: + continue + } + } + } catch { + Logger.warn("Paykit allowance recovery failed: \(error)", context: "PaykitAllowance") + } + } + + private func verifiedOutcome(for attempt: Paykit.PaymentAttemptRecord, identity: String) async -> Paykit.PaymentOutcome? { + switch attempt.status { + case .prepared: + return .failed + case .succeeded, .failed: + return nil + case .submitted, .unknown: + break + } + + guard let entry = localState(identity: identity).journal.first(where: { $0.attemptId == attempt.attemptId }) else { + return nil + } + switch entry.stage { + case .prepared, .submitted: + // The journal is written before the node call, so no payment left this wallet. + return .failed + case .succeeded: + return .succeeded + case .failed: + return .failed + case .sending, .sent, .unknown: + break + } + + if let paymentHash = entry.paymentHash { + switch await lightningLookup.status(paymentHash: paymentHash) { + case .succeeded: + return .succeeded + case .failed: + return .failed + case .pending, .unknown: + return nil + } + } + return entry.transactionId == nil ? nil : .succeeded + } + + private func record(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) async throws { + try await sdk.recordPaymentOutcome(Paykit.PaymentOutcomeReport(attemptId: attemptId, outcome: outcome)) + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + switch outcome { + case .succeeded: state.journal[index].stage = .succeeded + case .failed: state.journal[index].stage = .failed + case .unknown: state.journal[index].stage = .unknown + } + } + Self.eventSubject.send(.ledgerChanged) + } + + func automaticAttempts() async -> [PaykitAllowanceCapacity.Attempt] { + guard let state = try? await sdk.allowanceAccountingState() else { return [] } + return PaykitAllowanceCapacity.attempts(from: state.history) + } + + func succeededAutomaticPayments(identity: String) -> [PaykitAllowanceLocalState.JournalEntry] { + localState(identity: identity).journal.filter { $0.isAutomatic && $0.stage == .succeeded } + } + + // MARK: Automatic payment + + func autoPay( + _ request: PaykitPaymentRequest, + allowances: [PaykitAllowance], + identity: String + ) async -> PaykitAllowanceAutoPayResult { + guard request.direction == .incoming, + request.billingPeriod == nil, + request.lifecycleState == .proposed, + !inFlightRequestIds.contains(request.id), + allowances.contains(where: { + $0.isAllower && $0.lifecycleState == .accepted && + PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && + $0.counterpartyReceiverPath == request.counterpartyReceiverPath + }) + else { return .notCovered } + + inFlightRequestIds.insert(request.id) + defer { inFlightRequestIds.remove(request.id) } + + do { + try await ensureReconciled(identity: identity) + return try await admitAndPay(request, allowances: allowances, identity: identity) + } catch { + Logger.warn("Automatic allowance payment stayed manual: \(error)", context: "PaykitAllowance") + return .manual + } + } + + private func admitAndPay( + _ request: PaykitPaymentRequest, + allowances: [PaykitAllowance], + identity: String + ) async throws -> PaykitAllowanceAutoPayResult { + let scope = Paykit.PaymentRequestScope( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId + ) + let selectionTime = trustedTime(identity: identity) + let candidates = try await sdk.evaluateAllowanceCandidates(scope: scope, trustedTime: selectionTime) + guard let candidate = candidates.first(where: { $0.blocked == nil }), + let allowance = allowances.first(where: { $0.allowanceId == candidate.allowanceId }) + else { + let reasons = candidates.compactMap { $0.blocked.map { String(describing: $0) } } + Logger.info("No eligible allowance for an incoming request: \(reasons)", context: "PaykitAllowance") + return .manual + } + + let attempts = await automaticAttempts() + guard PaykitAllowanceCapacity.fits(amountSats: request.amountSats, allowance: allowance, attempts: attempts, now: now()) else { + Logger.info("Allowance monthly limit reached; the request stays on the manual flow", context: "PaykitAllowance") + notifyLimitReached(request, identity: identity) + return .manual + } + + let resolvedPayment: PrivatePaykitAllowancePayment? + do { + resolvedPayment = try await payer.resolve(request, eligibleIdentifiers: candidate.eligiblePaymentEndpointIdentifiers) + } catch PaykitAllowanceError.paymentListPending { + Logger.info("Deferred an incoming request until the payee publishes a new payment list", context: "PaykitAllowance") + return .deferred + } + guard let payment = resolvedPayment else { + Logger.info("No payable private endpoint for an allowance payment; leaving it manual", context: "PaykitAllowance") + return .manual + } + + let endpointIdentifier = payment.endpoint.methodId.rawValue + let association = try await sdk.acceptPaymentRequestAutomatically( + scope: scope, + selection: Paykit.AllowanceSelectionInput(allowanceId: candidate.allowanceId, expectedRevision: nil, trustedTime: selectionTime), + checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: selectionTime) + ) + try? await sdk.processOutboundPrivateMessages(counterparty: request.counterparty, counterpartyReceiverPath: request.counterpartyReceiverPath) + + let occurrence = Paykit.PaymentOccurrence(request: scope, billingPeriod: nil) + let reservation = try await sdk.reserveAutomaticPayment( + occurrence: occurrence, + expectedAssociationRevision: association.revisions.last?.revision ?? 1, + checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity)) + ) + guard case let .ready(prepared) = reservation else { + if case let .blocked(reason) = reservation { + Logger.info("Allowance reservation blocked: \(reason)", context: "PaykitAllowance") + } + try? await sdk.markPaymentManualOnly(occurrence: occurrence) + notifyLimitReached(request, identity: identity) + return .manual + } + + journal( + PaykitAllowanceLocalState.JournalEntry( + attemptId: prepared.attemptId, + isAutomatic: true, + requestId: request.id, + allowanceId: prepared.allowanceId, + amountSats: request.amountSats, + paymentEndpointIdentifier: endpointIdentifier, + paymentHash: payment.lightningPaymentHash, + onchainAddress: payment.endpoint.methodId.onchainNetwork == nil ? nil : payment.endpoint.value, + transactionId: nil, + stage: .prepared, + createdAt: now() + ), + identity: identity + ) + + // Begin fetches nothing, so pull the link first: an End or a cancellation must be seen before the handoff. + try? await sdk.receivePrivateMessages(counterparty: request.counterparty, counterpartyReceiverPath: request.counterpartyReceiverPath) + let handoff = try await sdk.beginPaymentExecution( + attemptId: prepared.attemptId, + checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity)) + ) + guard case let .ready(submitted) = handoff else { + try await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity) + return .manual + } + setStage(.submitted, attemptId: submitted.attemptId, identity: identity) + + do { + try await payer.consumePaymentList(publicKey: request.counterparty, context: payment.context) + try await payer.prepareProof(request, paymentEndpointIdentifier: endpointIdentifier, allowanceId: submitted.allowanceId) + } catch { + await payer.cancelProofPreparation(request) + try await record(attemptId: submitted.attemptId, outcome: .failed, identity: identity) + throw error + } + + if let paymentHash = payment.lightningPaymentHash { + return try await payLightning(request, payment: payment, paymentHash: paymentHash, attemptId: submitted.attemptId, identity: identity) + } + return try await payOnchain(request, payment: payment, attemptId: submitted.attemptId, identity: identity) + } + + private func payLightning( + _ request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + paymentHash: String, + attemptId: String, + identity: String + ) async throws -> PaykitAllowanceAutoPayResult { + do { + try await payer.associateLightningPayment(request, paymentHash: paymentHash) + } catch { + await payer.cancelProofPreparation(request) + try await record(attemptId: attemptId, outcome: .failed, identity: identity) + throw error + } + + setStage(.sending, attemptId: attemptId, identity: identity) + do { + try await payer.payLightning(bolt11: payment.endpoint.value, sats: payment.lightningInvoiceHasAmount ? nil : request.amountSats) + } catch { + // LDK rejected the payment before routing it. + await payer.failLightningPayment(paymentHash: paymentHash) + try await record(attemptId: attemptId, outcome: .failed, identity: identity) + throw error + } + setStage(.sent, attemptId: attemptId, identity: identity) + Logger.info("Handed an allowance payment to the node", context: "PaykitAllowance") + return .started + } + + private func payOnchain( + _ request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + attemptId: String, + identity: String + ) async throws -> PaykitAllowanceAutoPayResult { + let address = payment.endpoint.value + do { + try await payer.markOnchainPaymentStarted(request, address: address) + } catch { + await payer.cancelProofPreparation(request) + try await record(attemptId: attemptId, outcome: .failed, identity: identity) + throw error + } + + setStage(.sending, attemptId: attemptId, identity: identity) + let txid: String + do { + txid = try await payer.payOnchain(address: address, sats: request.amountSats) + } catch { + if PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error) { + await payer.cancelProofPreparation(request) + try await record(attemptId: attemptId, outcome: .failed, identity: identity) + } else { + try await record(attemptId: attemptId, outcome: .unknown, identity: identity) + } + throw error + } + + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + state.journal[index].transactionId = txid + } + await payer.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: payment.endpoint.methodId.rawValue) + try await record(attemptId: attemptId, outcome: .succeeded, identity: identity) + Self.eventSubject.send(.paidAutomatically(counterparty: request.counterparty, amountSats: request.amountSats, paymentId: txid)) + return .completed + } + + /// Called from the node's payment events for every outbound Lightning payment; only journaled ones are Allowance work. + func lightningPaymentSettled(paymentHash: String, succeeded: Bool) async { + guard let identity = activeIdentity else { return } + let entries = localState(identity: identity).journal.filter { + $0.paymentHash?.caseInsensitiveCompare(paymentHash) == .orderedSame && + [.sending, .sent, .unknown, .submitted].contains($0.stage) + } + for entry in entries { + do { + try await record(attemptId: entry.attemptId, outcome: succeeded ? .succeeded : .failed, identity: identity) + if succeeded, entry.isAutomatic { + Self.eventSubject.send( + .paidAutomatically(counterparty: entry.requestId.counterparty, amountSats: entry.amountSats, paymentId: paymentHash.lowercased()) + ) + } + } catch { + Logger.warn("Failed to record an allowance payment outcome: \(error)", context: "PaykitAllowance") + } + } + } + + // MARK: Manual payments + + /// Reports a user-approved payment of an incoming request to the shared ledger before it leaves the wallet. + /// Throws `alreadyRecorded` when another live attempt exists for the request, so the same request is never paid twice. + func beginManualPayment(_ request: PaykitPaymentRequest, paymentEndpointIdentifier: String) async throws -> String? { + guard let identity = activeIdentity, request.billingPeriod == nil, request.direction == .incoming else { return nil } + do { + try await ensureReconciled(identity: identity) + let scope = Paykit.PaymentRequestScope( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId + ) + let occurrence = Paykit.PaymentOccurrence(request: scope, billingPeriod: nil) + let decision = try await sdk.reserveManualPayment( + occurrence: occurrence, + checks: checks(request, endpointIdentifier: paymentEndpointIdentifier, trustedTime: trustedTime(identity: identity)) + ) + guard case let .ready(prepared) = decision else { + if case .blocked(.paymentAlreadyRecorded) = decision { + throw PaykitAllowanceManualPaymentError.alreadyRecorded + } + Logger.info("Manual payment not reported to the allowance ledger: \(decision)", context: "PaykitAllowance") + return nil + } + journal( + PaykitAllowanceLocalState.JournalEntry( + attemptId: prepared.attemptId, + isAutomatic: false, + requestId: request.id, + allowanceId: nil, + amountSats: request.amountSats, + paymentEndpointIdentifier: paymentEndpointIdentifier, + paymentHash: nil, + onchainAddress: nil, + transactionId: nil, + stage: .prepared, + createdAt: now() + ), + identity: identity + ) + let handoff = try await sdk.beginPaymentExecution( + attemptId: prepared.attemptId, + checks: checks(request, endpointIdentifier: paymentEndpointIdentifier, trustedTime: trustedTime(identity: identity)) + ) + guard case .ready = handoff else { + try await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity) + return nil + } + setStage(.submitted, attemptId: prepared.attemptId, identity: identity) + Logger.info("Reported a manual payment to the allowance ledger", context: "PaykitAllowance") + return prepared.attemptId + } catch let error as PaykitAllowanceManualPaymentError { + throw error + } catch { + Logger.warn("Manual payment not reported to the allowance ledger: \(error)", context: "PaykitAllowance") + return nil + } + } + + func manualLightningPaymentSent(attemptId: String, paymentHash: String) { + guard let identity = activeIdentity else { return } + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + state.journal[index].paymentHash = paymentHash.lowercased() + state.journal[index].stage = .sent + } + } + + func finishManualPayment(attemptId: String, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async { + guard let identity = activeIdentity else { return } + if let transactionId { + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + state.journal[index].transactionId = transactionId + } + } + do { + try await record(attemptId: attemptId, outcome: outcome, identity: identity) + } catch { + Logger.warn("Failed to record a manual payment outcome: \(error)", context: "PaykitAllowance") + } + } + + // MARK: Helpers + + private func checks(_ request: PaykitPaymentRequest, endpointIdentifier: String, trustedTime: String) throws -> Paykit.PaymentExecutionChecks { + try Paykit.PaymentExecutionChecks( + trustedTime: trustedTime, + paymentEndpointIdentifier: endpointIdentifier, + actualAmount: Paykit.AccountingAmount(value: request.amountValue, asset: PaykitIssuerInterop.bitcoinAsset), + endpointCurrent: true, + localEnabled: true, + recurrenceEligible: true + ) + } + + private func journal(_ entry: PaykitAllowanceLocalState.JournalEntry, identity: String) { + updateLocalState(identity: identity) { state in + state.journal.removeAll { $0.attemptId == entry.attemptId } + state.journal.append(entry) + if state.journal.count > 200 { + state.journal.removeFirst(state.journal.count - 200) + } + } + } + + private func setStage(_ stage: PaykitAllowanceLocalState.Stage, attemptId: String, identity: String) { + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + state.journal[index].stage = stage + } + } + + private func notifyLimitReached(_ request: PaykitPaymentRequest, identity: String) { + var isNew = false + updateLocalState(identity: identity) { state in + isNew = state.notifiedRequestIds.insert(request.paymentRequestId).inserted + } + if isNew { + Self.eventSubject.send(.limitReached(counterparty: request.counterparty, amountSats: request.amountSats)) + } + } +} diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift new file mode 100644 index 000000000..7cbaf9e7a --- /dev/null +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -0,0 +1,380 @@ +import Foundation +import Observation +import Paykit +import UserNotifications + +/// One grant as the user sees it. Bitkit proposes the same terms on each of a contact's links (their wallet, and +/// their Paykit Server folder for Locks and Shop requests), so one row can stand for several SDK Allowances. +struct PaykitAllowanceEntry: Identifiable, Hashable { + let id: String + let allowances: [PaykitAllowance] + let limits: PaykitAllowanceLimits? + + var primary: PaykitAllowance { + allowances.first { $0.counterpartyReceiverPath == PaykitReceiverPath.wallet } ?? allowances[0] + } + + var counterparty: String { primary.counterparty } + var role: PaykitAllowance.Role { primary.role } + var perPaymentMaxSats: UInt64? { primary.perPaymentMaxSats } + var monthlyLimitSats: UInt64? { primary.monthlyLimitSats } + var canEnd: Bool { allowances.contains(where: \.canEnd) } + + func status(at now: Date) -> PaykitAllowance.Status { + primary.status(at: now) + } +} + +enum PaykitAllowanceError: LocalizedError { + case contactNotLinked + case unavailable + case paymentListPending + + var errorDescription: String? { + switch self { + case .contactNotLinked: t("subscriptions__allowance_error_not_linked") + case .unavailable, .paymentListPending: t("subscriptions__allowance_error_unavailable") + } + } +} + +@Observable +@MainActor +final class PaykitAllowanceManager { + private(set) var allowances: [PaykitAllowance] = [] + private(set) var localState = PaykitAllowanceLocalState() + private(set) var isWorking = false + private(set) var autoPaidRequestIds: Set = [] + private(set) var autoPaidSatsByAllowanceId: [String: UInt64] = [:] + + @ObservationIgnored private let sdk: any PaykitAllowanceSdkHandling + @ObservationIgnored private let executor: PaykitAllowanceExecutor + @ObservationIgnored private let now: () -> Date + @ObservationIgnored private let canPayNow: @MainActor () -> Bool + @ObservationIgnored private var identity: String? + @ObservationIgnored private var isProcessingRequests = false + @ObservationIgnored private var manualRequestIds: [PaykitPaymentRequest.ID: Int] = [:] + /// Covered requests waiting to be paid automatically: kept off the Send sheet until paid or found manual. + @ObservationIgnored private var waitingRequestIds: Set = [] + + init( + sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared, + executor: PaykitAllowanceExecutor = .shared, + now: @escaping () -> Date = { Date() }, + canPayNow: @escaping @MainActor () -> Bool = PaykitAllowanceManager.lightningReady + ) { + self.sdk = sdk + self.executor = executor + self.now = now + self.canPayNow = canPayNow + } + + /// A node that just started lists its channels before they reconnect, and a payment sent then finds no route. + static func lightningReady() -> Bool { + guard LightningService.shared.status?.isRunning == true, + let channels = LightningService.shared.channels + else { return false } + return channels.isEmpty || channels.contains(where: \.isUsable) + } + + var entries: [PaykitAllowanceEntry] { + var grouped: [String: [PaykitAllowance]] = [:] + var order: [String] = [] + for allowance in allowances { + let key = localState.group(containing: allowance.allowanceId)?.id ?? allowance.allowanceId + if grouped[key] == nil { order.append(key) } + grouped[key, default: []].append(allowance) + } + return order.compactMap { key in + guard let allowances = grouped[key], !allowances.isEmpty else { return nil } + let limits = localState.groups.first { $0.id == key }?.limits + return PaykitAllowanceEntry(id: key, allowances: allowances, limits: limits) + } + } + + func entry(id: String) -> PaykitAllowanceEntry? { + entries.first { $0.id == id } + } + + func activate(identity: String?) async { + guard let identity else { + deactivate() + return + } + let identityChanged = self.identity != identity + self.identity = identity + await executor.activate(identity: identity) + if identityChanged { + manualRequestIds = [:] + waitingRequestIds = [] + await executor.recover(identity: identity) + } + await refresh() + } + + func deactivate() { + identity = nil + Task { await executor.activate(identity: nil) } + allowances = [] + localState = PaykitAllowanceLocalState() + autoPaidRequestIds = [] + autoPaidSatsByAllowanceId = [:] + manualRequestIds = [:] + waitingRequestIds = [] + } + + func refresh() async { + guard let identity else { return } + do { + let records = try await sdk.listAllowances( + filter: Paykit.AllowanceFilter(counterparty: nil, counterpartyReceiverPath: nil, localRole: nil, states: []) + ) + allowances = records + .filter { $0.historyStatus == .consistent || $0.historyStatus == .unresolvedReferences } + .compactMap(PaykitAllowance.init(record:)) + } catch { + Logger.warn("Failed to list Paykit allowances: \(error)", context: "PaykitAllowance") + } + localState = await executor.localState(identity: identity) + let paid = await executor.succeededAutomaticPayments(identity: identity) + autoPaidRequestIds = Set(paid.map(\.requestId)) + autoPaidSatsByAllowanceId = paid.reduce(into: [:]) { totals, entry in + guard let allowanceId = entry.allowanceId else { return } + totals[allowanceId, default: 0] += entry.amountSats + } + } + + func autoPaidSats(for entry: PaykitAllowanceEntry) -> UInt64 { + entry.allowances.reduce(0) { $0 + (autoPaidSatsByAllowanceId[$1.allowanceId] ?? 0) } + } + + /// Whether an incoming request is covered by an active Allowance this wallet granted. A request created before the + /// Allowance was accepted stays manual: it may already have been shown to the user for a decision. + func coversRequest(_ request: PaykitPaymentRequest) -> Bool { + allowances.contains { allowance in + guard allowance.isAllower, allowance.status(at: now()) == .active, + PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty), + allowance.counterpartyReceiverPath == request.counterpartyReceiverPath + else { return false } + guard let acceptedAt = allowance.lastEventAt, let createdAt = request.createdAt else { return true } + return createdAt >= acceptedAt.addingTimeInterval(-Self.acceptanceClockTolerance) + } + } + + private var allowancesSignature: Int { + var hasher = Hasher() + for allowance in allowances { + hasher.combine(allowance.allowanceId) + hasher.combine(allowance.lifecycleState.rawDescription) + } + hasher.combine(autoPaidSatsByAllowanceId.values.reduce(0, +)) + return hasher.finalize() + } + + // MARK: Lifecycle + + func propose(to contact: PubkyContact, limits: PaykitAllowanceLimits) async throws { + guard let identity else { throw PaykitAllowanceError.unavailable } + isWorking = true + defer { isWorking = false } + + let peers = try await sdk.linkedPeers().filter { + PubkyPublicKeyFormat.matches($0.counterparty, contact.publicKey) && $0.state == .linked + } + let receiverPaths = Self.orderedReceiverPaths(peers.map(\.counterpartyReceiverPath)) + guard !receiverPaths.isEmpty else { throw PaykitAllowanceError.contactNotLinked } + + let terms = try limits.terms( + monthAnchor: PaykitAllowanceTime.monthStart(containing: now()), + allowedPaymentEndpointIdentifiers: Self.allowedPaymentEndpointIdentifiers + ) + var allowanceIds: [String] = [] + for receiverPath in receiverPaths { + do { + let record = try await sdk.proposeAllowance( + counterparty: contact.publicKey, + counterpartyReceiverPath: receiverPath, + localRole: .allower, + terms: terms + ) + allowanceIds.append(record.allowanceId) + try? await sdk.processOutboundPrivateMessages(counterparty: contact.publicKey, counterpartyReceiverPath: receiverPath) + } catch where receiverPath != PaykitReceiverPath.wallet { + Logger.warn("Could not propose the allowance on a secondary link: \(error)", context: "PaykitAllowance") + } + } + + let group = PaykitAllowanceLocalState.Group( + id: UUID().uuidString.lowercased(), + counterparty: contact.publicKey, + limits: limits, + allowanceIds: allowanceIds, + createdAt: now() + ) + await executor.updateLocalState(identity: identity) { $0.groups.append(group) } + Logger.info("Proposed an allowance on \(allowanceIds.count) link(s)", context: "PaykitAllowance") + await refresh() + } + + func accept(_ entry: PaykitAllowanceEntry) async throws { + try await respond(to: entry) { allowance in + try await self.sdk.acceptAllowance( + counterparty: allowance.counterparty, + counterpartyReceiverPath: allowance.counterpartyReceiverPath, + allowanceId: allowance.allowanceId + ) + } + } + + func reject(_ entry: PaykitAllowanceEntry) async throws { + try await respond(to: entry) { allowance in + try await self.sdk.rejectAllowance( + counterparty: allowance.counterparty, + counterpartyReceiverPath: allowance.counterpartyReceiverPath, + allowanceId: allowance.allowanceId + ) + } + } + + func end(_ entry: PaykitAllowanceEntry) async throws { + isWorking = true + defer { isWorking = false } + var endedAny = false + for allowance in entry.allowances where allowance.canEnd { + _ = try await sdk.endAllowance( + counterparty: allowance.counterparty, + counterpartyReceiverPath: allowance.counterpartyReceiverPath, + allowanceId: allowance.allowanceId + ) + endedAny = true + try? await sdk.processOutboundPrivateMessages( + counterparty: allowance.counterparty, + counterpartyReceiverPath: allowance.counterpartyReceiverPath + ) + } + guard endedAny else { throw PaykitAllowanceError.unavailable } + await refresh() + } + + private func respond(to entry: PaykitAllowanceEntry, _ response: (PaykitAllowance) async throws -> Paykit.AllowanceRecord) async throws { + isWorking = true + defer { isWorking = false } + var respondedAny = false + for allowance in entry.allowances where allowance.isAnswerable { + _ = try await response(allowance) + respondedAny = true + try? await sdk.processOutboundPrivateMessages( + counterparty: allowance.counterparty, + counterpartyReceiverPath: allowance.counterpartyReceiverPath + ) + } + guard respondedAny else { throw PaykitAllowanceError.unavailable } + if let identity { + let ids = entry.allowances.map(\.allowanceId) + await executor.updateLocalState(identity: identity) { $0.presentedProposalIds.formUnion(ids) } + } + await refresh() + } + + // MARK: Presentation + + func proposalForPresentation() -> PaykitAllowanceEntry? { + entries.first { entry in + entry.allowances.contains(where: \.isAnswerable) && + !entry.allowances.contains { localState.presentedProposalIds.contains($0.allowanceId) } + } + } + + func markProposalPresented(_ entry: PaykitAllowanceEntry) async { + guard let identity else { return } + let ids = entry.allowances.map(\.allowanceId) + await executor.updateLocalState(identity: identity) { $0.presentedProposalIds.formUnion(ids) } + localState.presentedProposalIds.formUnion(ids) + } + + // MARK: Automatic payments + + /// Pays incoming requests that an active Allowance covers. Returns whether any request was handled, so the + /// caller refreshes before presenting the rest for manual payment. + func processIncomingRequests(_ requests: [PaykitPaymentRequest]) async -> Bool { + guard let identity, !isProcessingRequests else { return false } + let signature = allowancesSignature + let covered = requests.filter { + $0.requiresAcceptance && coversRequest($0) && manualRequestIds[$0.id] != signature + } + guard !covered.isEmpty else { return false } + guard canPayNow() else { + waitingRequestIds.formUnion(covered.map(\.id)) + return false + } + + isProcessingRequests = true + defer { isProcessingRequests = false } + var handledAny = false + for request in covered { + let result = await executor.autoPay(request, allowances: allowances, identity: identity) + switch result { + case .started, .completed: + handledAny = true + waitingRequestIds.remove(request.id) + case .manual: + manualRequestIds[request.id] = signature + waitingRequestIds.remove(request.id) + case .deferred: + waitingRequestIds.insert(request.id) + case .notCovered: + waitingRequestIds.remove(request.id) + } + } + if handledAny { + await refresh() + } + return handledAny + } + + func isAutomaticallyHandling(_ request: PaykitPaymentRequest) async -> Bool { + if waitingRequestIds.contains(request.id), coversRequest(request) { return true } + return await executor.isHandling(request.id) + } + + static let acceptanceClockTolerance: TimeInterval = 30 + + static let allowedPaymentEndpointIdentifiers: [String] = PaykitIssuerInterop.supportedEndpointIdentifiers( + PublicPaykitService.MethodId.publishableMethodIds.map(\.rawValue), + network: Env.network + ) + + static func orderedReceiverPaths(_ paths: [String]) -> [String] { + let unique = Array(Set(paths)) + return unique.sorted { lhs, rhs in + if lhs == PaykitReceiverPath.wallet { return true } + if rhs == PaykitReceiverPath.wallet { return false } + return lhs < rhs + } + } +} + +/// Allowance outcomes reach the user as a notification banner when notifications are allowed, or as a toast. +enum PaykitAllowanceNotifier { + @MainActor + static func post(title: String, body: String, fallback: @escaping @MainActor () -> Void) { + Task { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { + fallback() + return + } + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.userInfo = ["bitkit_action": "paykit_allowance"] + do { + try await center.add(UNNotificationRequest(identifier: "paykit-allowance-\(UUID().uuidString)", content: content, trigger: nil)) + } catch { + fallback() + } + } + } +} diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 81c98bf21..ce15d4699 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -26,6 +26,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { let paymentEndpointIdentifier: String let kind: PaykitPaymentProofKind let billingPeriod: PaykitBillingPeriod? + /// Set only for an automatic Allowance payment, from its succeeded attempt. Manual payments omit it. + var allowanceId: String? var paymentStarted: Bool var paymentIdentifier: String? var proofData: String? @@ -39,6 +41,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind, billingPeriod: PaykitBillingPeriod? = nil, + allowanceId: String? = nil, paymentStarted: Bool = false, paymentIdentifier: String?, proofData: String?, @@ -51,6 +54,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { self.paymentEndpointIdentifier = paymentEndpointIdentifier self.kind = kind self.billingPeriod = billingPeriod + self.allowanceId = allowanceId self.paymentStarted = paymentStarted self.paymentIdentifier = paymentIdentifier self.proofData = proofData @@ -218,13 +222,15 @@ actor PaykitPaymentProofService { func prepare( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, - kind: PaykitPaymentProofKind + kind: PaykitPaymentProofKind, + allowanceId: String? = nil ) async throws { - let proof = try await pendingProof( + var proof = try await pendingProof( request: request, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind ) + proof.allowanceId = allowanceId var pendingProofs = try await loadProofs() guard !pendingProofs.contains(where: { @@ -608,6 +614,7 @@ actor PaykitPaymentProofService { proof: Paykit.PaymentProofSubmission( billingPeriod: pendingProof.billingPeriod?.sdkValue, paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier, + allowanceId: pendingProof.allowanceId, proof: Paykit.PrivateJsonObject(text: proofText) ) ) diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 83e2a1350..3e7f4622e 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -1,3 +1,4 @@ +import BitkitCore import Foundation import Paykit @@ -274,6 +275,62 @@ extension PrivatePaykitService { persistState(markWalletBackup: true) } + /// Resolves the payee's current private endpoint for an automatic Allowance payment. Only endpoints the Allowance and + /// the request both accept qualify, and only ones this wallet can pay without asking: a bolt11 invoice for exactly + /// the requested amount (or amount-less), or an unused on-chain address. Public endpoints are never used. + func resolveAllowancePayment( + _ request: PaykitPaymentRequest, + eligibleIdentifiers: [String] + ) async throws -> PrivatePaykitAllowancePayment? { + guard !request.isExpired(at: Date()), + let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty) + else { return nil } + + let consumedVersion = state.contacts[publicKey]? + .consumedPrivatePaymentListVersionsByReceiverPath[request.counterpartyReceiverPath] + let prepared = try await PaykitSdkService.shared.prepareAndResolvePrivateContactPayment( + counterparty: publicKey, + receiverPath: request.counterpartyReceiverPath, + amount: PaymentAmountContext(value: request.amountValue, asset: PaykitIssuerInterop.bitcoinAsset), + afterPrivatePaymentListVersion: consumedVersion + ) + if prepared.resolution.state == .recoveryPending || prepared.resolution.status == .waitingForUpdatedPaymentList { + // The last list was already paid from; a new one arrives once the payee sees that payment settle. + schedulePrivatePaymentRecovery(for: publicKey, receiverPath: request.counterpartyReceiverPath) + throw PaykitAllowanceError.paymentListPending + } + guard let paymentListVersion = prepared.resolution.privatePaymentListVersion else { return nil } + + let eligible = Set(eligibleIdentifiers).intersection(request.acceptedPaymentEndpointIdentifiers) + let candidates = resolvedEndpoints(from: prepared.resolution).filter { + eligible.contains($0.methodId.rawValue) && + ($0.methodId == .bitcoinLightningBolt11 || $0.methodId.onchainNetwork != nil) + } + let payable = await privatePayableEndpoints(from: candidates, publicKey: publicKey) + + for methodId in PublicPaykitService.MethodId.payablePreferenceOrder { + guard let endpoint = payable.first(where: { $0.methodId == methodId }) else { continue } + if methodId == .bitcoinLightningBolt11 { + guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value), + invoice.amountSatoshis == 0 || invoice.amountSatoshis == request.amountSats + else { continue } + return PrivatePaykitAllowancePayment( + endpoint: endpoint, + context: PrivatePaykitPaymentContext(receiverPath: request.counterpartyReceiverPath, paymentListVersion: paymentListVersion), + lightningPaymentHash: invoice.paymentHash.hex, + lightningInvoiceHasAmount: invoice.amountSatoshis != 0 + ) + } + return PrivatePaykitAllowancePayment( + endpoint: endpoint, + context: PrivatePaykitPaymentContext(receiverPath: request.counterpartyReceiverPath, paymentListVersion: paymentListVersion), + lightningPaymentHash: nil, + lightningInvoiceHasAmount: false + ) + } + return nil + } + private func resolvedEndpoints(from resolution: PrivateContactPaymentResolution) -> [PublicPaykitService.Endpoint] { resolution.payableEndpoints.compactMap { return PublicPaykitService.parseEndpoint(identifier: $0.identifier, payload: $0.target.payload) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index a08c8410c..deab9f9b9 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -362,6 +362,7 @@ actor PaykitSdkService { func initialize() async throws { Task { await republishIdentityIfNeeded() } try await operationLock.withLock { + await resolveLegacyStateLayoutIfNeeded() var sdk = try handle() do { _ = try await sdk.initialize() @@ -999,6 +1000,133 @@ actor PaykitSdkService { } } + func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] { + try await operationLock.withLock { + try await handle().listAllowances(filter: filter) + } + } + + func proposeAllowance( + counterparty: String, + counterpartyReceiverPath: String, + localRole: Paykit.AllowanceLocalRole, + terms: Paykit.AllowanceTerms + ) async throws -> Paykit.AllowanceRecord { + try await withStateRevisionTracking { sdk in + try await sdk.proposeAllowance( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + localRole: localRole, + terms: terms + ) + } + } + + func acceptAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + try await withStateRevisionTracking { sdk in + try await sdk.acceptAllowance(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath, allowanceId: allowanceId) + } + } + + func rejectAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + try await withStateRevisionTracking { sdk in + try await sdk.rejectAllowance(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath, allowanceId: allowanceId) + } + } + + func endAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + try await withStateRevisionTracking { sdk in + try await sdk.endAllowance(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath, allowanceId: allowanceId) + } + } + + @discardableResult + func receivePrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.PrivateStreamIntakeReport { + try await withStateRevisionTracking { sdk in + try await sdk.receivePrivateMessages(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath) + } + } + + @discardableResult + func processOutboundPrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.OutboundPrivateSendReport { + try await withStateRevisionTracking { sdk in + try await sdk.processOutboundPrivateMessages(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath) + } + } + + func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? { + try await operationLock.withLock { + try await handle().allowanceAccountingState() + } + } + + func reconcileAllowanceAccounting( + _ reconciliation: Paykit.AllowanceAccountingReconciliation + ) async throws -> Paykit.AllowanceAccountingState { + try await withStateRevisionTracking { sdk in + try await sdk.reconcileAllowanceAccounting(reconciliation: reconciliation) + } + } + + func evaluateAllowanceCandidates(scope: Paykit.PaymentRequestScope, trustedTime: String) async throws -> [Paykit.AllowanceCandidate] { + try await withStateRevisionTracking { sdk in + try await sdk.evaluateAllowanceCandidates(scope: scope, trustedTime: trustedTime) + } + } + + func acceptPaymentRequestAutomatically( + scope: Paykit.PaymentRequestScope, + selection: Paykit.AllowanceSelectionInput, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.AllowanceAssociationRecord { + try await withStateRevisionTracking { sdk in + try await sdk.acceptPaymentRequestAutomatically(scope: scope, selection: selection, checks: checks) + } + } + + func reserveAutomaticPayment( + occurrence: Paykit.PaymentOccurrence, + expectedAssociationRevision: UInt64, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.PaymentAttemptDecision { + try await withStateRevisionTracking { sdk in + try await sdk.reserveAutomaticPayment( + occurrence: occurrence, + expectedAssociationRevision: expectedAssociationRevision, + checks: checks + ) + } + } + + func reserveManualPayment( + occurrence: Paykit.PaymentOccurrence, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.PaymentAttemptDecision { + try await withStateRevisionTracking { sdk in + try await sdk.reserveManualPayment(occurrence: occurrence, checks: checks) + } + } + + func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision { + try await withStateRevisionTracking { sdk in + try await sdk.beginPaymentExecution(attemptId: attemptId, checks: checks) + } + } + + @discardableResult + func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord { + try await withStateRevisionTracking { sdk in + try await sdk.recordPaymentOutcome(report: report) + } + } + + @discardableResult + func markPaymentManualOnly(occurrence: Paykit.PaymentOccurrence) async throws -> Paykit.PaymentOccurrenceRecord { + try await withStateRevisionTracking { sdk in + try await sdk.markPaymentManualOnly(occurrence: occurrence) + } + } + func linkedPeers() async throws -> [LinkedPeerRecord] { try await operationLock.withLock { try await handle().linkedPeers() @@ -1100,6 +1228,42 @@ actor PaykitSdkService { try Paykit.requiredSessionCapabilities(config: config()) } + /// A state blob saved before Bitkit recorded layouts may come from an rc55 build. The SDK itself tells which layout + /// decodes: a throwaway instance reads each candidate from memory, and the store records the one that works. + private func resolveLegacyStateLayoutIfNeeded() async { + guard let legacy = stateStore.unmarkedBlob() else { return } + var decodable: [(layout: PaykitSdkStateLayout, hasIdentity: Bool)] = [] + for layout in [PaykitSdkStateLayout.allowances, .rc55] { + do { + let probe = try PaykitSdk( + stateStore: PaykitSdkProbeStateStore(bytes: layout.sdkBytes(fromStored: legacy.bytes)), + sessionProvider: PaykitSdkProbeSessionProvider(), + config: Self.config() + ) + _ = try await probe.allowanceAccountingState() + let hasIdentity = await (try? probe.identityStatus())??.publicKey != nil + decodable.append((layout, hasIdentity)) + } catch { + continue + } + } + // Postcard ignores trailing bytes, so a wrong layout can occasionally parse; the one that still holds the + // wallet's identity wins. + guard let chosen = decodable.first(where: \.hasIdentity) ?? decodable.first else { + Logger.error("Stored Paykit state matches no known layout", context: "PaykitSdkService") + return + } + do { + try stateStore.markLayout(chosen.layout, expectedRevision: legacy.revision) + Logger.info( + "Resolved the stored Paykit state layout as \(chosen.layout.rawValue) (\(decodable.count) candidate(s) decoded)", + context: "PaykitSdkService" + ) + } catch { + Logger.warn("Failed to record the Paykit state layout: \(error)", context: "PaykitSdkService") + } + } + private func handle() throws -> PaykitSdk { if let sdk { return sdk @@ -1349,6 +1513,45 @@ extension PublicPaykitService.Endpoint { } } +/// Paykit #161 added `allowance_accounting` as the first field of the SDK state without bumping the state blob version, +/// so the two layouts differ by one Option tag right after the version byte. Bitkit keeps the rc55 layout on disk while +/// accounting is empty, so an rc55 build can still read the wallet, and records the layout it wrote in the revision. +enum PaykitSdkStateLayout: String { + case rc55 + case allowances = "alw" + + private static let versionByte: UInt8 = 0x01 + private static let noneTag: UInt8 = 0x00 + + static func stored(fromSdk bytes: Data) -> (bytes: Data, layout: PaykitSdkStateLayout) { + let start = bytes.startIndex + guard bytes.count >= 2, bytes[start] == versionByte, bytes[start + 1] == noneTag else { + return (bytes, .allowances) + } + var stored = bytes + stored.remove(at: start + 1) + return (stored, .rc55) + } + + func sdkBytes(fromStored bytes: Data) -> Data { + guard self == .rc55, let first = bytes.first, first == Self.versionByte else { return bytes } + var sdkBytes = bytes + sdkBytes.insert(Self.noneTag, at: sdkBytes.startIndex + 1) + return sdkBytes + } + + static func split(revision: String) -> (base: String, layout: PaykitSdkStateLayout?) { + guard let dot = revision.lastIndex(of: "."), + let layout = PaykitSdkStateLayout(rawValue: String(revision[revision.index(after: dot)...])) + else { return (revision, nil) } + return (String(revision[.. String { + "\(base).\(rawValue)" + } +} + private final class PaykitSdkStateBlobStore: SdkStateBlobStore, @unchecked Sendable { private let lock = NSLock() @@ -1362,7 +1565,10 @@ private final class PaykitSdkStateBlobStore: SdkStateBlobStore, @unchecked Senda return nil } - return try decodeSdkStateBlobSnapshot(bytes: data) + let snapshot = try decodeSdkStateBlobSnapshot(bytes: data) + let layout = PaykitSdkStateLayout.split(revision: snapshot.revision).layout ?? .allowances + let bytes = layout.sdkBytes(fromStored: snapshot.blob.exportBytes()) + return SdkStateBlobSnapshot(blob: SdkStateBlob(bytes: bytes), revision: snapshot.revision) } func saveStateBlobAtomically(blob: SdkStateBlob, expectedRevision: String?) throws -> String { @@ -1375,12 +1581,65 @@ private final class PaykitSdkStateBlobStore: SdkStateBlobStore, @unchecked Senda throw PaykitError.Storage(code: "revision_conflict", context: "SDK state revision changed") } - let nextRevision = UUID().uuidString - let snapshot = SdkStateBlobSnapshot(blob: blob, revision: nextRevision) + let stored = PaykitSdkStateLayout.stored(fromSdk: blob.exportBytes()) + let nextRevision = stored.layout.revision(UUID().uuidString) + let snapshot = SdkStateBlobSnapshot(blob: SdkStateBlob(bytes: stored.bytes), revision: nextRevision) let encoded = try encodeSdkStateBlobSnapshot(snapshot: snapshot) try Keychain.upsert(key: .paykitSdkState, data: encoded) return nextRevision } + + func unmarkedBlob() -> (bytes: Data, revision: String)? { + lock.lock() + defer { lock.unlock() } + + guard let data = try? Keychain.load(key: .paykitSdkState), + let snapshot = try? decodeSdkStateBlobSnapshot(bytes: data), + PaykitSdkStateLayout.split(revision: snapshot.revision).layout == nil + else { return nil } + return (snapshot.blob.exportBytes(), snapshot.revision) + } + + func markLayout(_ layout: PaykitSdkStateLayout, expectedRevision: String) throws { + lock.lock() + defer { lock.unlock() } + + guard let data = try Keychain.load(key: .paykitSdkState) else { return } + let snapshot = try decodeSdkStateBlobSnapshot(bytes: data) + guard snapshot.revision == expectedRevision else { + throw PaykitError.Storage(code: "revision_conflict", context: "SDK state revision changed") + } + let marked = SdkStateBlobSnapshot(blob: snapshot.blob, revision: layout.revision(snapshot.revision)) + try Keychain.upsert(key: .paykitSdkState, data: encodeSdkStateBlobSnapshot(snapshot: marked)) + } +} + +private final class PaykitSdkProbeStateStore: SdkStateBlobStore, @unchecked Sendable { + private let bytes: Data + + init(bytes: Data) { + self.bytes = bytes + } + + func loadStateBlob() throws -> SdkStateBlobSnapshot? { + SdkStateBlobSnapshot(blob: SdkStateBlob(bytes: bytes), revision: "probe") + } + + func saveStateBlobAtomically(blob _: SdkStateBlob, expectedRevision _: String?) throws -> String { + throw PaykitError.Storage(code: "read_only", context: "Paykit state layout probe is read-only") + } +} + +private final class PaykitSdkProbeSessionProvider: SdkPubkySessionProvider, @unchecked Sendable { + func loadSessionAccess() throws -> PubkySessionAccess? { + nil + } + + func publicStorageAvailable() throws -> Bool { + false + } + + func clearSessionAccess() throws {} } private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendable { diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index e72092955..157b7cd2e 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -31,6 +31,13 @@ struct PrivatePaykitPaymentContext: Equatable { let paymentListVersion: UInt64 } +struct PrivatePaykitAllowancePayment: Equatable { + let endpoint: PublicPaykitService.Endpoint + let context: PrivatePaykitPaymentContext + let lightningPaymentHash: String? + let lightningInvoiceHasAmount: Bool +} + enum IncomingPaykitPaymentRequestFailureReason: String, Hashable { case noSupportedEndpoint = "no_supported_endpoint" case endpointNotPayable = "endpoint_not_payable" diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 9097577ea..f96d3e374 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -10,6 +10,7 @@ enum KeychainEntryType { case paykitPendingPaymentProofs case paykitPresentedPaymentRequests case paykitSubscriptionState + case paykitAllowanceState case paykitReceiverNoiseSecretKey case paykitSdkState case pubkySecretKey @@ -24,6 +25,7 @@ enum KeychainEntryType { case .paykitPendingPaymentProofs: "paykit_pending_payment_proofs" case .paykitPresentedPaymentRequests: "paykit_presented_payment_requests" case .paykitSubscriptionState: "paykit_subscription_state" + case .paykitAllowanceState: "paykit_allowance_state" case .paykitReceiverNoiseSecretKey: "paykit_receiver_noise_secret_key" case .paykitSdkState: "paykit_sdk_state" case .pubkySecretKey: "pubky_secret_key" diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index d60344933..c975bf7e0 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1284,6 +1284,7 @@ extension AppViewModel { paymentHash: paymentHash, preimage: paymentPreimage ) + await PaykitAllowanceExecutor.shared.lightningPaymentSettled(paymentHash: paymentHash, succeeded: true) } let outcome = QuickPayPaymentCoordinator.shared.complete( paymentId: paymentId, @@ -1317,7 +1318,10 @@ extension AppViewModel { ) let hash = paymentHash ?? outcome.invoicePaymentHash ?? paymentId if let paymentHash = paymentHash ?? paymentId { - Task { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) } + Task { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) + await PaykitAllowanceExecutor.shared.lightningPaymentSettled(paymentHash: paymentHash, succeeded: false) + } } let awaitingSheet = hash.map { pendingPaymentHashes.contains($0) } ?? false if let hash, awaitingSheet { diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index d462025dd..1b33bcbfe 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -312,6 +312,7 @@ struct PaymentRequestsView: View { @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var sheets: SheetViewModel @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + @Environment(PaykitAllowanceManager.self) private var allowances var body: some View { Group { @@ -462,7 +463,9 @@ struct PaymentRequestsView: View { private func historyDate(for request: PaykitPaymentRequest) -> String { guard let createdAt = request.createdAt else { return status(for: request) } - return Self.dateFormatter.string(from: createdAt) + let date = Self.dateFormatter.string(from: createdAt) + guard allowances.autoPaidRequestIds.contains(request.id) else { return date } + return date + " · " + t("subscriptions__allowance_auto_paid") } private static let dateFormatter: DateFormatter = { diff --git a/Bitkit/Views/Subscriptions/AllowancesView.swift b/Bitkit/Views/Subscriptions/AllowancesView.swift new file mode 100644 index 000000000..6850d3882 --- /dev/null +++ b/Bitkit/Views/Subscriptions/AllowancesView.swift @@ -0,0 +1,744 @@ +import SwiftUI + +// MARK: - Allowances tab + +struct AllowancesTab: View { + @Environment(PaykitAllowanceManager.self) private var allowances + @EnvironmentObject private var sheets: SheetViewModel + + var body: some View { + Group { + if allowances.entries.isEmpty { + emptyState + } else { + list + } + } + .task { + await allowances.refresh() + } + } + + private var list: some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + LazyVStack(spacing: 12) { + ForEach(allowances.entries) { entry in + Button { + let route: SubscriptionSheetItem.Route = entry.primary.isAnswerable + ? .allowanceReview(entry) + : .allowanceDetail(entry) + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: route)) + } label: { + AllowanceRow(entry: entry, now: context.date) + } + .buttonStyle(.plain) + } + } + .padding(.top, 32) + .padding(.bottom, ScreenLayout.floatingFooterClearance) + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer() + + Image("group") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + + Spacer().frame(height: 32) + + DisplayText(t("subscriptions__allowances_empty_headline"), accentColor: .purpleAccent) + Spacer().frame(height: 8) + BodyMText(t("subscriptions__allowances_empty_description"), textColor: .white64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, ScreenLayout.floatingFooterClearance) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("AllowancesEmpty") + } +} + +struct AllowanceRow: View { + @Environment(PaykitAllowanceManager.self) private var allowances + @EnvironmentObject private var currency: CurrencyViewModel + + let entry: PaykitAllowanceEntry + let now: Date + + private var status: PaykitAllowance.Status { + entry.status(at: now) + } + + var body: some View { + HStack(spacing: 16) { + AllowanceCounterpartyAvatar(counterparty: entry.counterparty, size: 40) + + VStack(alignment: .leading, spacing: 0) { + AllowanceCounterpartyName(counterparty: entry.counterparty) + CaptionBText(subtitle, textColor: .white64) + .lineLimit(1) + .accessibilityIdentifier("AllowanceRowStatus") + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 0) { + AllowanceMoney(usd: entry.limits?.monthlyUsd, sats: entry.monthlyLimitSats, size: .bodyMSB) + CaptionBText(t("subscriptions__allowance_monthly_limit"), textColor: .white64) + .lineLimit(1) + } + } + .padding(16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .opacity(isInactive ? 0.64 : 1) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("AllowanceRow-\(entry.id)") + } + + private var isInactive: Bool { + switch status { + case .ended, .declined, .expired, .conflicted: true + default: false + } + } + + private var subtitle: String { + let perPayment = AllowanceAmountText.perPayment(entry, currency: currency) + switch status { + case .active: + return [t("subscriptions__allowance_status_active"), perPayment].compactMap { $0 }.joined(separator: " · ") + case .awaitingAnswer: + return t("subscriptions__allowance_status_waiting") + case .awaitingMyAnswer: + return t("subscriptions__allowance_status_needs_answer") + case .notYetActive: + return t("subscriptions__allowance_status_scheduled") + case .expired: + return t("subscriptions__allowance_status_expired") + case .declined: + return t("subscriptions__allowance_status_declined") + case .conflicted: + return t("subscriptions__allowance_status_conflicted") + case .ended: + let paid = allowances.autoPaidSats(for: entry) + guard paid > 0 else { return t("subscriptions__allowance_status_ended") } + return t("subscriptions__allowance_status_ended") + " · " + + t("subscriptions__allowance_paid_automatically", variables: ["amount": AllowanceAmountText.fiat(sats: paid, currency: currency)]) + } + } +} + +// MARK: - Shared pieces + +struct AllowanceCounterpartyAvatar: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let counterparty: String + let size: CGFloat + + var body: some View { + if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, counterparty) }) { + PubkyContactAvatar(contact: contact, size: size) + } else { + ContactAvatarLetter(source: counterparty, size: size) + } + } +} + +struct AllowanceCounterpartyName: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let counterparty: String + + var body: some View { + BodyMSBText(name) + .lineLimit(1) + } + + private var name: String { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, counterparty) }?.displayName + ?? PubkyPublicKeyFormat.displayTruncated(counterparty) + } +} + +/// A limit shown in dollars: the label the Allower picked when there is one, otherwise the BTC terms at today's rate. +struct AllowanceMoney: View { + @EnvironmentObject private var currency: CurrencyViewModel + + enum Size { + case bodyMSB + case title + } + + let usd: Decimal? + let sats: UInt64? + var size: Size = .bodyMSB + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 3) { + text("$", color: .white64) + text(amount, color: .textPrimary) + } + } + + private var amount: String { + if let usd { + return AllowanceAmountText.formatted(usd) + } + guard let sats else { return "—" } + return AllowanceAmountText.limitValue(sats: sats, currency: currency) + } + + @ViewBuilder + private func text(_ value: String, color: Color) -> some View { + switch size { + case .bodyMSB: BodyMSBText(value, textColor: color) + case .title: TitleText(value, textColor: color) + } + } +} + +enum AllowanceAmountText { + static func formatted(_ usd: Decimal) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + formatter.locale = Locale(identifier: "en_US") + return formatter.string(from: usd as NSDecimalNumber) ?? "\(usd)" + } + + static func short(_ usd: Decimal) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + formatter.locale = Locale(identifier: "en_US") + return "$" + (formatter.string(from: usd as NSDecimalNumber) ?? "\(usd)") + } + + @MainActor + static func fiatValue(sats: UInt64, currency: CurrencyViewModel) -> String { + guard let converted = currency.convert(sats: sats, to: "USD") else { return "—" } + return formatted(converted.value) + } + + @MainActor + static func fiat(sats: UInt64, currency: CurrencyViewModel) -> String { + "$" + fiatValue(sats: sats, currency: currency) + } + + /// A limit set in whole dollars on the other wallet, shown back from its BTC terms at today's rate: rounded to the + /// dollar so a small rate move does not turn $5 into $4.99. + @MainActor + static func limitValue(sats: UInt64, currency: CurrencyViewModel) -> String { + guard let converted = currency.convert(sats: sats, to: "USD") else { return "—" } + guard converted.value >= 1 else { return formatted(converted.value) } + var rounded = Decimal() + var value = converted.value + NSDecimalRound(&rounded, &value, 0, .plain) + return formatted(rounded) + } + + @MainActor + static func perPayment(_ entry: PaykitAllowanceEntry, currency: CurrencyViewModel) -> String? { + if let usd = entry.limits?.perPaymentUsd { + return t("subscriptions__allowance_per_payment_short", variables: ["amount": short(usd)]) + } + guard let sats = entry.perPaymentMaxSats else { return nil } + return t("subscriptions__allowance_per_payment_short", variables: ["amount": "$" + limitValue(sats: sats, currency: currency)]) + } +} + +private struct AllowanceLimitsGrid: View { + @EnvironmentObject private var currency: CurrencyViewModel + + let entry: PaykitAllowanceEntry + + var body: some View { + HStack(alignment: .top, spacing: 16) { + cell( + title: t("subscriptions__allowance_per_payment"), + usd: entry.limits?.perPaymentUsd, + sats: entry.perPaymentMaxSats, + identifier: "AllowancePerPaymentValue" + ) + cell( + title: t("subscriptions__allowance_each_month"), + usd: entry.limits?.monthlyUsd, + sats: entry.monthlyLimitSats, + identifier: "AllowanceMonthlyValue" + ) + } + } + + private func cell(title: String, usd: Decimal?, sats: UInt64?, identifier: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(title.localizedUppercase, textColor: .white64) + BodySSBText(t("subscriptions__allowance_up_to", variables: ["amount": usd.map { "$" + AllowanceAmountText.formatted($0) } ?? sats.map { "$" + AllowanceAmountText.limitValue(sats: $0, currency: currency) } ?? "—"])) + .accessibilityIdentifier(identifier) + if let sats { + CaptionText("₿ " + sats.formattedWithSpaces, textColor: .white64) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct AllowanceCounterpartyCard: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let counterparty: String + + var body: some View { + HStack(spacing: 16) { + AllowanceCounterpartyAvatar(counterparty: counterparty, size: 48) + VStack(alignment: .leading, spacing: 0) { + CaptionMText(PubkyPublicKeyFormat.displayTruncated(counterparty).localizedUppercase, textColor: .white64) + AllowanceCounterpartyName(counterparty: counterparty) + } + Spacer() + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("AllowanceCounterparty") + } +} + +extension UInt64 { + var formattedWithSpaces: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.groupingSeparator = " " + formatter.usesGroupingSeparator = true + return formatter.string(from: NSNumber(value: self)) ?? "\(self)" + } +} + +// MARK: - Choose a contact + +struct AllowanceContactView: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let onSelect: (PubkyContact) -> Void + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__allowance_choose_contact")) + + if contactsManager.contacts.isEmpty { + BodyMText(t("subscriptions__allowance_no_contacts"), textColor: .white64) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 16) + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(contactsManager.contacts) { contact in + Button { + onSelect(contact) + } label: { + HStack(spacing: 16) { + PubkyContactAvatar(contact: contact, size: 48) + VStack(alignment: .leading, spacing: 0) { + CaptionMText(contact.profile.truncatedPublicKey.localizedUppercase, textColor: .white64) + BodyMSBText(contact.displayName) + } + Spacer() + } + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("AllowanceContact-\(contact.displayName)") + CustomDivider() + } + } + } + } + } + .padding(.horizontal, 16) + } +} + +// MARK: - Set Allowance + +struct SetAllowanceView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var currency: CurrencyViewModel + @Environment(PaykitAllowanceManager.self) private var allowances + + let contact: PubkyContact + let onBack: () -> Void + let onSaved: () -> Void + + @State private var perPaymentIndex = 1 + @State private var monthlyIndex = 2 + + private var perPaymentUsd: Decimal { PaykitAllowanceLimits.perPaymentStopsUsd[perPaymentIndex] } + private var monthlyUsd: Decimal { PaykitAllowanceLimits.monthlyStopsUsd[monthlyIndex] } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("subscriptions__allowance_set_title"), showBackButton: true, onBack: onBack) + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + AllowanceCounterpartyCard(counterparty: contact.publicKey) + + BodyMText( + t("subscriptions__allowance_set_explanation", variables: ["name": contact.displayName]), + textColor: .white64 + ) + + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("subscriptions__allowance_payment_limit").localizedUppercase, textColor: .white64) + .padding(.vertical, 16) + AllowanceStepSlider( + stops: PaykitAllowanceLimits.perPaymentStopsUsd, + selectedIndex: $perPaymentIndex, + identifier: "AllowancePerPayment" + ) + } + + CustomDivider() + + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("subscriptions__allowance_monthly_allowance").localizedUppercase, textColor: .white64) + .padding(.vertical, 16) + AllowanceStepSlider( + stops: PaykitAllowanceLimits.monthlyStopsUsd, + selectedIndex: $monthlyIndex, + identifier: "AllowanceMonthly" + ) + } + + CustomDivider() + + BodySText( + t( + "subscriptions__allowance_set_summary", + variables: [ + "perPayment": AllowanceAmountText.short(perPaymentUsd), + "monthly": AllowanceAmountText.short(monthlyUsd), + ] + ), + textColor: .white64 + ) + .accessibilityIdentifier("AllowanceSummary") + } + .padding(.bottom, 16) + } + + CustomButton(title: t("subscriptions__allowance_save"), isLoading: allowances.isWorking) { + await save() + } + .accessibilityIdentifier("AllowanceSave") + .padding(.bottom, 16) + } + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SetAllowance") + } + + private func save() async { + guard let perPaymentSats = currency.convert(fiatAmount: NSDecimalNumber(decimal: perPaymentUsd).doubleValue, from: "USD"), + let monthlySats = currency.convert(fiatAmount: NSDecimalNumber(decimal: monthlyUsd).doubleValue, from: "USD") + else { + app.toast(PaykitAllowanceError.unavailable) + return + } + let limits = PaykitAllowanceLimits( + perPaymentUsd: perPaymentUsd, + monthlyUsd: monthlyUsd, + perPaymentSats: perPaymentSats, + monthlySats: monthlySats + ) + do { + try await allowances.propose(to: contact, limits: limits) + onSaved() + } catch { + app.toast(error) + } + } +} + +/// A slider that snaps to fixed stops, drawn like the Figma allowance limits: a track, a tick per stop, and a knob. +struct AllowanceStepSlider: View { + let stops: [Decimal] + @Binding var selectedIndex: Int + let identifier: String + + private let knobSize: CGFloat = 32 + private let trackHeight: CGFloat = 8 + + var body: some View { + VStack(spacing: 8) { + GeometryReader { geometry in + let width = geometry.size.width + ZStack(alignment: .leading) { + Capsule() + .fill(Color.purpleAccent.opacity(0.32)) + .frame(height: trackHeight) + Capsule() + .fill(Color.purpleAccent) + .frame(width: position(for: selectedIndex, width: width), height: trackHeight) + ForEach(stops.indices, id: \.self) { index in + RoundedRectangle(cornerRadius: 2) + .fill(Color.white) + .frame(width: 4, height: 16) + .offset(x: min(max(position(for: index, width: width) - 2, 0), width - 4)) + } + Circle() + .fill(Color.purpleAccent) + .frame(width: knobSize, height: knobSize) + .overlay(Circle().fill(Color.white).frame(width: 16, height: 16)) + .offset(x: position(for: selectedIndex, width: width) - knobSize / 2) + } + .frame(height: knobSize) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + select(nearestIndex(to: value.location.x, width: width)) + } + ) + } + .frame(height: knobSize) + + GeometryReader { geometry in + ZStack(alignment: .topLeading) { + ForEach(stops.indices, id: \.self) { index in + Button { + select(index) + } label: { + CaptionMText(AllowanceAmountText.short(stops[index]), textColor: .textPrimary) + .frame(width: labelWidth, alignment: alignment(for: index)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .offset(x: labelOffset(for: index, width: geometry.size.width)) + .accessibilityIdentifier("\(identifier)Stop-\(index)") + } + } + } + .frame(height: 18) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(identifier) + .accessibilityValue(AllowanceAmountText.short(stops[selectedIndex])) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: select(selectedIndex + 1) + case .decrement: select(selectedIndex - 1) + @unknown default: break + } + } + } + + private func select(_ index: Int) { + let clamped = min(max(index, 0), stops.count - 1) + guard clamped != selectedIndex else { return } + selectedIndex = clamped + Haptics.play(.light) + } + + private func position(for index: Int, width: CGFloat) -> CGFloat { + guard stops.count > 1 else { return width / 2 } + return width * CGFloat(index) / CGFloat(stops.count - 1) + } + + private func nearestIndex(to x: CGFloat, width: CGFloat) -> Int { + guard stops.count > 1, width > 0 else { return 0 } + return Int((x / width * CGFloat(stops.count - 1)).rounded()) + } + + private let labelWidth: CGFloat = 56 + + private func labelOffset(for index: Int, width: CGFloat) -> CGFloat { + if index == 0 { return 0 } + if index == stops.count - 1 { return width - labelWidth } + return position(for: index, width: width) - labelWidth / 2 + } + + private func alignment(for index: Int) -> Alignment { + if index == 0 { return .leading } + if index == stops.count - 1 { return .trailing } + return .center + } +} + +// MARK: - Review (the side that answers a proposal) + +struct AllowanceReviewView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @Environment(PaykitAllowanceManager.self) private var allowances + + let entry: PaykitAllowanceEntry + + private var counterpartyName: String { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, entry.counterparty) }?.displayName + ?? PubkyPublicKeyFormat.displayTruncated(entry.counterparty) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("subscriptions__allowance_title")) + + DisplayText( + entry.role == .allowee + ? t("subscriptions__allowance_offer_headline", variables: ["name": counterpartyName]) + : t("subscriptions__allowance_request_headline", variables: ["name": counterpartyName]), + accentColor: .purpleAccent + ) + .padding(.top, 16) + .padding(.bottom, 16) + + AllowanceCounterpartyCard(counterparty: entry.counterparty) + .padding(16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + AllowanceLimitsGrid(entry: entry) + .padding(.top, 24) + + BodyMText( + entry.role == .allowee + ? t("subscriptions__allowance_offer_explanation", variables: ["name": counterpartyName]) + : t("subscriptions__allowance_request_explanation", variables: ["name": counterpartyName]), + textColor: .white64 + ) + .padding(.top, 24) + + Spacer() + + HStack(spacing: 16) { + CustomButton(title: t("subscriptions__allowance_decline"), variant: .secondary, isDisabled: allowances.isWorking) { + await respond(accept: false) + } + .accessibilityIdentifier("AllowanceDecline") + CustomButton(title: t("subscriptions__allowance_accept"), isLoading: allowances.isWorking) { + await respond(accept: true) + } + .accessibilityIdentifier("AllowanceAccept") + } + .padding(.bottom, 16) + } + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("AllowanceReview") + .task { + // Another sheet's dismissal can close this one right after it opens; only a sheet the user saw counts. + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + await allowances.markProposalPresented(entry) + } + } + + private func respond(accept: Bool) async { + do { + if accept { + try await allowances.accept(entry) + } else { + try await allowances.reject(entry) + } + sheets.hideSheet(reason: accept ? "Allowance accepted" : "Allowance declined") + } catch { + app.toast(error) + } + } +} + +// MARK: - Detail + +struct AllowanceDetailView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var sheets: SheetViewModel + @Environment(PaykitAllowanceManager.self) private var allowances + + let entry: PaykitAllowanceEntry + + private var current: PaykitAllowanceEntry { + allowances.entry(id: entry.id) ?? entry + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("subscriptions__allowance_title")) + + AllowanceCounterpartyCard(counterparty: current.counterparty) + .padding(16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + AllowanceLimitsGrid(entry: current) + .padding(.top, 24) + + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__allowance_paid_so_far").localizedUppercase, textColor: .white64) + BodySSBText(AllowanceAmountText.fiat(sats: allowances.autoPaidSats(for: current), currency: currency)) + .accessibilityIdentifier("AllowancePaidSoFar") + } + .padding(.top, 24) + + BodyMText(explanation, textColor: .white64) + .padding(.top, 24) + .accessibilityIdentifier("AllowanceDetailStatus") + + Spacer() + + if current.canEnd { + SwipeButton( + title: current.primary.lifecycleState == .proposed + ? t("subscriptions__allowance_swipe_withdraw") + : t("subscriptions__allowance_swipe_end"), + accentColor: .purpleAccent, + isLoading: allowances.isWorking + ) { + do { + try await allowances.end(current) + sheets.hideSheet(reason: "Allowance ended") + } catch { + app.toast(error) + throw error + } + } + .padding(.bottom, 16) + } + } + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("AllowanceDetail") + } + + private var explanation: String { + switch current.status(at: Date()) { + case .active: + current.role == .allower + ? t("subscriptions__allowance_detail_active_allower") + : t("subscriptions__allowance_detail_active_allowee") + case .awaitingAnswer: + t("subscriptions__allowance_status_waiting") + case .awaitingMyAnswer: + t("subscriptions__allowance_status_needs_answer") + case .notYetActive: + t("subscriptions__allowance_status_scheduled") + case .expired: + t("subscriptions__allowance_status_expired") + case .declined: + t("subscriptions__allowance_status_declined") + case .ended: + t("subscriptions__allowance_detail_ended") + case .conflicted: + t("subscriptions__allowance_status_conflicted") + } + } +} diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 04f2f4350..9564baa8d 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -12,6 +12,10 @@ struct SubscriptionSheetItem: SheetItem { case details(PaykitSubscription) case cancel(PaykitSubscription) case payment(SendRoute) + case allowanceContact + case allowanceSet(PubkyContact) + case allowanceReview(PaykitAllowanceEntry) + case allowanceDetail(PaykitAllowanceEntry) } let route: Route @@ -22,11 +26,13 @@ struct SubscriptionSheetItem: SheetItem { struct SubscriptionsView: View { private enum Tab: String, CustomStringConvertible { case overview + case allowances case payments var description: String { switch self { case .overview: t("subscriptions__overview") + case .allowances: t("subscriptions__allowances") case .payments: t("subscriptions__payments") } } @@ -73,6 +79,8 @@ struct SubscriptionsView: View { Group { if selectedTab == .payments { PaymentRequestsView() + } else if selectedTab == .allowances { + AllowancesTab() } else if !hasVisibleSubscriptions { emptyState } else { @@ -118,6 +126,7 @@ struct SubscriptionsView: View { selectedTab: $selectedTab, tabItems: [ TabItem(.overview), + TabItem(.allowances), TabItem(.payments, badge: paymentRequests.pendingRequests.count), ], inactiveColor: .white.opacity(0.5) @@ -148,6 +157,11 @@ struct SubscriptionsView: View { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .create)) } .accessibilityIdentifier("SubscriptionCreate") + } else if selectedTab == .allowances { + CustomButton(title: t("subscriptions__allowance_add")) { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .allowanceContact)) + } + .accessibilityIdentifier("AllowanceAdd") } else { PaymentRequestsFooterButton() } @@ -597,6 +611,18 @@ struct SubscriptionSheet: View { cancel(subscription) case let .payment(sendRoute): SendSheet(config: SendSheetItem(initialRoute: sendRoute), isEmbedded: true) + case .allowanceContact: + AllowanceContactView { route = .allowanceSet($0) } + case let .allowanceSet(contact): + SetAllowanceView( + contact: contact, + onBack: { route = .allowanceContact }, + onSaved: { sheets.hideSheet(reason: "Allowance proposed") } + ) + case let .allowanceReview(entry): + AllowanceReviewView(entry: entry) + case let .allowanceDetail(entry): + AllowanceDetailView(entry: entry) } } .task { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 99636948e..406e0f26f 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -700,6 +700,7 @@ struct SendConfirmationView: View { var preparedPaymentProof: (endpointIdentifier: String, kind: PaykitPaymentProofKind)? var onchainPaymentStarted = false var lightningPaymentSubmitted = false + var manualAllowanceAttemptId: String? do { try validateIncomingPaymentRequestContext(contactPaymentContext) @@ -725,6 +726,13 @@ struct SendConfirmationView: View { return } + if let incomingPaymentRequest, let preparedPaymentProof { + manualAllowanceAttemptId = try await PaykitAllowanceExecutor.shared.beginManualPayment( + incomingPaymentRequest, + paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier + ) + } + if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis // Set the amount for other screens @@ -738,6 +746,9 @@ struct SendConfirmationView: View { paymentHash: paymentHash ) } + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.manualLightningPaymentSent(attemptId: manualAllowanceAttemptId, paymentHash: paymentHash) + } createdMetadataPaymentId = paymentHash await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash) @@ -799,6 +810,13 @@ struct SendConfirmationView: View { } } shouldCancelPaymentProof = false + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.finishManualPayment( + attemptId: manualAllowanceAttemptId, + outcome: .succeeded, + transactionId: txid + ) + } if let incomingPaymentRequest, let preparedPaymentProof { await PaykitPaymentProofService.shared.completeOnchainPayment( incomingPaymentRequest, @@ -832,6 +850,9 @@ struct SendConfirmationView: View { ) } } catch is CancellationError { + if let manualAllowanceAttemptId, !lightningPaymentSubmitted, !onchainPaymentStarted { + await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: manualAllowanceAttemptId, outcome: .failed) + } if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } @@ -854,6 +875,12 @@ struct SendConfirmationView: View { return } } + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.finishManualPayment( + attemptId: manualAllowanceAttemptId, + outcome: lightningPaymentSubmitted || onchainPaymentStarted ? .unknown : .failed + ) + } if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift new file mode 100644 index 000000000..8e4596eee --- /dev/null +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -0,0 +1,957 @@ +@testable import Bitkit +import Combine +import Foundation +import Paykit +import XCTest + +final class PaykitAllowanceExecutorTests: XCTestCase { + private typealias Fixtures = PaykitAllowanceFixtures + + private static let admissionCalls = [ + "evaluateAllowanceCandidates", + "acceptPaymentRequestAutomatically", + "reserveAutomaticPayment", + "receivePrivateMessages", + "beginPaymentExecution", + "consumePaymentList", + "prepareProof", + "associateLightningPayment", + "payLightning", + ] + + // MARK: Admission + + func testUncoveredRequestNeverReachesTheSdk() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let uncovering: [[PaykitAllowance]] = [ + [], + [Fixtures.allowance(role: .allowee)], + [Fixtures.allowance(state: .proposed)], + [Fixtures.allowance(state: .ended)], + [Fixtures.allowance(receiverPath: PaykitReceiverPath.server)], + [Fixtures.allowance(counterparty: Fixtures.otherCounterpartyKey)], + ] + + for allowances in uncovering { + let result = await harness.executor.autoPay(request, allowances: allowances, identity: Fixtures.identityKey) + XCTAssertEqual(result, .notCovered) + } + XCTAssertEqual(harness.log.entries, []) + } + + func testCoveredLightningRequestRunsAdmissionInOrderAndHandsOffToTheNode() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .started) + let log = harness.log.entries + XCTAssertEqual(log.filter(Self.admissionCalls.contains), Self.admissionCalls) + let resolveIndex = try XCTUnwrap(log.firstIndex(of: "resolve")) + let acceptIndex = try XCTUnwrap(log.firstIndex(of: "acceptPaymentRequestAutomatically")) + XCTAssertLessThan(resolveIndex, acceptIndex) + XCTAssertFalse(log.contains("recordPaymentOutcome")) + + let evaluatedTimes = await harness.sdk.evaluatedTrustedTimes + XCTAssertEqual(evaluatedTimes, [PaykitAllowanceTime.format(Fixtures.now)]) + let selections = await harness.sdk.selections + XCTAssertEqual(selections.map(\.allowanceId), [Fixtures.walletAllowanceId]) + let acceptedEndpoints = await harness.sdk.acceptedEndpointIdentifiers + XCTAssertEqual(acceptedEndpoints, [Fixtures.lightningIdentifier]) + let reservedRevisions = await harness.sdk.reservedAssociationRevisions + XCTAssertEqual(reservedRevisions, [1]) + let preparedProofs = await harness.payer.preparedProofs + XCTAssertEqual(preparedProofs, [.init(endpoint: Fixtures.lightningIdentifier, allowanceId: Fixtures.walletAllowanceId)]) + let associatedHashes = await harness.payer.associatedPaymentHashes + XCTAssertEqual(associatedHashes, [AllowanceHarness.paymentHash]) + let payments = await harness.payer.lightningPayments + XCTAssertEqual(payments, [.init(bolt11: AllowanceHarness.invoice, sats: nil)]) + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.count, 1) + let entry = try XCTUnwrap(journal.first) + XCTAssertEqual(entry.attemptId, AllowanceSdkMock.automaticAttemptId) + XCTAssertEqual(entry.stage, .sent) + XCTAssertTrue(entry.isAutomatic) + XCTAssertEqual(entry.requestId, request.id) + XCTAssertEqual(entry.allowanceId, Fixtures.walletAllowanceId) + XCTAssertEqual(entry.amountSats, 1000) + XCTAssertEqual(entry.paymentHash, AllowanceHarness.paymentHash) + XCTAssertEqual(entry.paymentEndpointIdentifier, Fixtures.lightningIdentifier) + let isHandling = await harness.executor.isHandling(request.id) + XCTAssertFalse(isHandling) + } + + func testRequestWaitsWithoutAcceptanceWhileThePayeesPaymentListIsPending() async throws { + let harness = AllowanceHarness() + await harness.payer.setResolveError(PaykitAllowanceError.paymentListPending) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .deferred) + let log = harness.log.entries + XCTAssertTrue(log.contains("resolve")) + XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically")) + XCTAssertFalse(log.contains("markPaymentManualOnly")) + } + + @MainActor + func testManagerKeepsCoveredRequestsOffTheSendSheetUntilLightningIsReady() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + var ready = false + let manager = PaykitAllowanceManager( + sdk: harness.sdk, + executor: harness.executor, + now: { PaykitAllowanceFixtures.now }, + canPayNow: { ready } + ) + await manager.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest() + + let handledWhileReconnecting = await manager.processIncomingRequests([request]) + + XCTAssertFalse(handledWhileReconnecting) + XCTAssertFalse(harness.log.entries.contains("evaluateAllowanceCandidates")) + let waiting = await manager.isAutomaticallyHandling(request) + XCTAssertTrue(waiting) + + ready = true + let handled = await manager.processIncomingRequests([request]) + + XCTAssertTrue(handled) + XCTAssertTrue(harness.log.entries.contains("payLightning")) + } + + @MainActor + func testManagerKeepsADeferredRequestOffTheSendSheet() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + await harness.payer.setResolveError(PaykitAllowanceError.paymentListPending) + let manager = PaykitAllowanceManager( + sdk: harness.sdk, + executor: harness.executor, + now: { PaykitAllowanceFixtures.now }, + canPayNow: { true } + ) + await manager.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest() + + let handled = await manager.processIncomingRequests([request]) + + XCTAssertFalse(handled) + let waiting = await manager.isAutomaticallyHandling(request) + XCTAssertTrue(waiting) + } + + func testBlockedCandidateStaysManualWithoutAcceptance() async throws { + let harness = AllowanceHarness() + await harness.sdk.setCandidates([ + AllowanceHarness.candidate(blocked: .sharedRule(code: "amount_outside_range")), + ]) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let log = harness.log.entries + XCTAssertTrue(log.contains("evaluateAllowanceCandidates")) + XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically")) + XCTAssertFalse(log.contains("reserveAutomaticPayment")) + XCTAssertFalse(log.contains("resolve")) + XCTAssertFalse(log.contains("payLightning")) + } + + func testOverMonthlyCapStaysManualAndNotifiesOnce() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setAccountingState(Self.stateNearTheMonthlyCap()) + let request = try Fixtures.paymentRequest() + let events = AllowanceEventRecorder() + + let first = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + let second = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(first, .manual) + XCTAssertEqual(second, .manual) + let log = harness.log.entries + XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically")) + XCTAssertFalse(log.contains("reserveAutomaticPayment")) + XCTAssertFalse(log.contains("resolve")) + let limitEvents = events.events.filter { $0 == .limitReached(counterparty: Fixtures.counterpartyKey, amountSats: 1000) } + XCTAssertEqual(limitEvents.count, 1) + } + + func testBlockedReservationMarksThePaymentManualOnly() async throws { + let harness = AllowanceHarness() + await harness.sdk.setAutomaticReservation(.blocked(reason: .sharedRule(code: "period_amount_limit_exceeded"))) + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let manualOnly = await harness.sdk.manualOnlyOccurrences + XCTAssertEqual(manualOnly, [ + Paykit.PaymentOccurrence( + request: Paykit.PaymentRequestScope( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId + ), + billingPeriod: nil + ), + ]) + let log = harness.log.entries + XCTAssertFalse(log.contains("beginPaymentExecution")) + XCTAssertFalse(log.contains("payLightning")) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal, []) + } + + func testBlockedBeginRecordsAFailedOutcome() async throws { + let harness = AllowanceHarness() + await harness.sdk.setBeginDecision(.blocked(reason: .manualOnly)) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.failed]) + let log = harness.log.entries + XCTAssertFalse(log.contains("consumePaymentList")) + XCTAssertFalse(log.contains("prepareProof")) + XCTAssertFalse(log.contains("payLightning")) + } + + // MARK: Settlement and recovery + + func testLightningSettlementRecordsSuccessForTheJournaledAttempt() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry( + attemptId: "attempt-1", + request: request, + stage: .sent, + paymentHash: AllowanceHarness.paymentHash + )]), + identity: Fixtures.identityKey + ) + await harness.executor.activate(identity: Fixtures.identityKey) + let events = AllowanceEventRecorder() + + await harness.executor.lightningPaymentSettled(paymentHash: String(repeating: "f", count: 64), succeeded: true) + let outcomesForUnknownHash = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomesForUnknownHash, []) + + await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash.uppercased(), succeeded: true) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: "attempt-1", outcome: .succeeded)]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + let paid = await harness.executor.succeededAutomaticPayments(identity: Fixtures.identityKey) + XCTAssertEqual(paid.map(\.attemptId), ["attempt-1"]) + let paidEvents = events.events.filter { + $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: AllowanceHarness.paymentHash) + } + XCTAssertEqual(paidEvents.count, 1) + XCTAssertEqual(harness.log.entries.filter { $0 == "payLightning" || $0 == "payOnchain" }, []) + } + + func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + let pendingHash = String(repeating: "2", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-prepared", attempts: [Fixtures.attemptRecord(id: "prepared", status: .prepared)]), + Fixtures.occurrence(requestId: "req-old-epoch", attempts: [Fixtures.attemptRecord(id: "old-epoch", status: .prepared, epoch: "epoch-0")]), + Fixtures.occurrence(requestId: "req-never-sent", attempts: [Fixtures.attemptRecord(id: "never-sent", status: .submitted)]), + Fixtures.occurrence(requestId: "req-sent-paid", attempts: [Fixtures.attemptRecord(id: "sent-paid", status: .submitted)]), + Fixtures.occurrence(requestId: "req-sent-pending", attempts: [Fixtures.attemptRecord(id: "sent-pending", status: .submitted)]), + Fixtures.occurrence(requestId: "req-done", attempts: [Fixtures.attemptRecord(id: "done", status: .succeeded)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [ + Self.journalEntry(attemptId: "prepared", request: request, stage: .prepared), + Self.journalEntry(attemptId: "never-sent", request: request, stage: .submitted), + Self.journalEntry(attemptId: "sent-paid", request: request, stage: .sent, paymentHash: paidHash), + Self.journalEntry(attemptId: "sent-pending", request: request, stage: .sent, paymentHash: pendingHash), + ]), + identity: Fixtures.identityKey + ) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64)), pendingHash: .pending]) + + await harness.executor.recover(identity: Fixtures.identityKey) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual( + Dictionary(uniqueKeysWithValues: outcomes.map { ($0.attemptId, $0.outcome) }), + ["prepared": .failed, "never-sent": .failed, "sent-paid": .succeeded, "sent-pending": .unknown] + ) + XCTAssertEqual(outcomes.count, 4) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual( + Dictionary(uniqueKeysWithValues: journal.map { ($0.attemptId, $0.stage) }), + ["prepared": .failed, "never-sent": .failed, "sent-paid": .succeeded, "sent-pending": .unknown] + ) + let payerCalls = await harness.payer.callCount + XCTAssertEqual(payerCalls, 0, "Recovery must never touch the payer, so nothing is paid twice") + XCTAssertEqual(harness.log.entries.filter { $0 == "payLightning" || $0 == "payOnchain" }, []) + let reconciliations = await harness.sdk.reconciliations + XCTAssertEqual(reconciliations.count, 0) + } + + // MARK: Manual payments + + func testManualPaymentThrowsWhenTheRequestIsAlreadyRecorded() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + await harness.sdk.setManualReservation(.blocked(reason: .paymentAlreadyRecorded)) + let request = try Fixtures.paymentRequest() + + do { + _ = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier) + XCTFail("Expected alreadyRecorded") + } catch PaykitAllowanceManualPaymentError.alreadyRecorded { + // expected + } catch { + XCTFail("Unexpected error: \(error)") + } + let log = harness.log.entries + XCTAssertFalse(log.contains("beginPaymentExecution")) + } + + func testManualPaymentIsJournaledAndSubmittedWhenReady() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest() + + let attemptId = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier) + + XCTAssertEqual(attemptId, AllowanceSdkMock.manualAttemptId) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.submitted]) + XCTAssertEqual(journal.first?.isAutomatic, false) + + await harness.sdk.setManualReservation(.blocked(reason: .manualOnly)) + let blocked = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier) + XCTAssertNil(blocked) + } + + // MARK: Trusted time and reconciliation + + func testTrustedTimeNeverMovesBackwards() async { + let harness = AllowanceHarness() + let start = Fixtures.now + + let first = await harness.executor.trustedTime(identity: Fixtures.identityKey) + harness.clock.set(start.addingTimeInterval(-3600)) + let afterClockMovedBack = await harness.executor.trustedTime(identity: Fixtures.identityKey) + harness.clock.set(start.addingTimeInterval(60)) + let afterClockMovedForward = await harness.executor.trustedTime(identity: Fixtures.identityKey) + + XCTAssertEqual(first, PaykitAllowanceTime.format(start)) + XCTAssertEqual(afterClockMovedBack, PaykitAllowanceTime.format(start)) + XCTAssertEqual(afterClockMovedForward, PaykitAllowanceTime.format(start.addingTimeInterval(60))) + let stored = await harness.executor.localState(identity: Fixtures.identityKey).lastTrustedTime + XCTAssertEqual(stored, start.addingTimeInterval(60)) + } + + func testMissingLedgerIsReconciledWithAnEmptyHistory() async throws { + let harness = AllowanceHarness() + await harness.sdk.setAccountingState(nil) + + let reconciled = try await harness.executor.ensureReconciled(identity: Fixtures.identityKey) + _ = try await harness.executor.ensureReconciled(identity: Fixtures.identityKey) + + let reconciliations = await harness.sdk.reconciliations + XCTAssertEqual(reconciliations.count, 1) + let reconciliation = try XCTUnwrap(reconciliations.first) + XCTAssertNil(reconciliation.expectedRevision) + XCTAssertTrue(reconciliation.history.associations.isEmpty) + XCTAssertTrue(reconciliation.history.occurrences.isEmpty) + XCTAssertTrue(reconciliation.history.watermarks.isEmpty) + XCTAssertEqual(reconciliation.outcomes, []) + XCTAssertEqual(reconciliation.trustedTime, PaykitAllowanceTime.format(Fixtures.now)) + XCTAssertFalse(reconciled.requiresReconciliation) + } + + func testLedgerThatRequiresReconciliationIsReconciledAtItsRevision() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "3", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 7, requiresReconciliation: true, occurrences: [ + Fixtures.occurrence(requestId: "req-prepared", attempts: [Fixtures.attemptRecord(id: "prepared", status: .prepared)]), + Fixtures.occurrence(requestId: "req-sent-paid", attempts: [Fixtures.attemptRecord(id: "sent-paid", status: .submitted)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "sent-paid", request: request, stage: .sent, paymentHash: paidHash)]), + identity: Fixtures.identityKey + ) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: nil)]) + + try await harness.executor.ensureReconciled(identity: Fixtures.identityKey) + + let reconciliations = await harness.sdk.reconciliations + XCTAssertEqual(reconciliations.count, 1) + let reconciliation = try XCTUnwrap(reconciliations.first) + XCTAssertEqual(reconciliation.expectedRevision, 7) + XCTAssertEqual(reconciliation.history.occurrences.flatMap(\.attempts).map(\.attemptId), ["prepared", "sent-paid"]) + XCTAssertEqual(reconciliation.outcomes, [ + Paykit.PaymentOutcomeReport(attemptId: "prepared", outcome: .failed), + Paykit.PaymentOutcomeReport(attemptId: "sent-paid", outcome: .succeeded), + ]) + let payerCalls = await harness.payer.callCount + XCTAssertEqual(payerCalls, 0) + } + + // MARK: Trusted time vs demo clock + + func testAdmissionUsesInjectedTimeWhileDemoClockIsOffset() async throws { + snapshotAppDefaults(DemoClock.offsetDaysKey) + UserDefaults.standard.set(400, forKey: DemoClock.offsetDaysKey) + try XCTSkipUnless(DemoClock.offsetDays() == 400, "The demo clock is unavailable in this build") + let harness = AllowanceHarness() + try await harness.sdk.setAccountingState(Self.stateNearTheMonthlyCap()) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual, "September's paid attempts must count; the demo clock would have moved the window a year ahead") + let evaluatedTimes = await harness.sdk.evaluatedTrustedTimes + XCTAssertEqual(evaluatedTimes, [PaykitAllowanceTime.format(Fixtures.now)]) + XCTAssertFalse(harness.log.entries.contains("acceptPaymentRequestAutomatically")) + } + + // MARK: Manager + + @MainActor + func testManagerGroupsOneGrantAcrossLinksWithTheWalletLinkAsPrimary() async throws { + let harness = AllowanceHarness() + let terms = try Fixtures.standardTerms() + await harness.sdk.setRecords([ + Fixtures.record(allowanceId: Fixtures.serverAllowanceId, receiverPath: PaykitReceiverPath.server, terms: terms), + Fixtures.record(allowanceId: Fixtures.walletAllowanceId, receiverPath: PaykitReceiverPath.wallet, terms: terms), + Fixtures.record(allowanceId: "allowance-other", counterparty: Fixtures.otherCounterpartyKey, terms: terms), + Fixtures.record(allowanceId: "allowance-invalid", historyStatus: .invalid, terms: terms), + ]) + let group = PaykitAllowanceLocalState.Group( + id: "group-1", + counterparty: Fixtures.counterpartyKey, + limits: Fixtures.limits, + allowanceIds: [Fixtures.walletAllowanceId, Fixtures.serverAllowanceId], + createdAt: Fixtures.now + ) + harness.store.seed(PaykitAllowanceLocalState(groups: [group]), identity: Fixtures.identityKey) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + let entries = manager.entries + XCTAssertEqual(entries.map(\.id), ["group-1", "allowance-other"]) + let grouped = try XCTUnwrap(entries.first) + XCTAssertEqual(grouped.allowances.map(\.allowanceId), [Fixtures.serverAllowanceId, Fixtures.walletAllowanceId]) + XCTAssertEqual(grouped.primary.allowanceId, Fixtures.walletAllowanceId) + XCTAssertEqual(grouped.primary.counterpartyReceiverPath, PaykitReceiverPath.wallet) + XCTAssertEqual(grouped.limits, Fixtures.limits) + XCTAssertEqual(grouped.counterparty, Fixtures.counterpartyKey) + XCTAssertEqual(grouped.role, .allower) + XCTAssertEqual(grouped.perPaymentMaxSats, 5000) + XCTAssertEqual(grouped.monthlyLimitSats, 50000) + XCTAssertEqual(grouped.status(at: Fixtures.now), .active) + XCTAssertNil(entries.last?.limits) + XCTAssertEqual(manager.entry(id: "group-1")?.primary.allowanceId, Fixtures.walletAllowanceId) + } + + @MainActor + func testManagerCoverageUsesInjectedTimeWhileDemoClockIsOffset() async throws { + snapshotAppDefaults(DemoClock.offsetDaysKey) + UserDefaults.standard.set(400, forKey: DemoClock.offsetDaysKey) + try XCTSkipUnless(DemoClock.offsetDays() == 400, "The demo clock is unavailable in this build") + let harness = AllowanceHarness() + let expiring = try Fixtures.customTerms(expiresAt: Fixtures.now.addingTimeInterval(30 * 24 * 60 * 60)) + await harness.sdk.setRecords([Fixtures.record(terms: expiring)]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest())) + XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey))) + } + + @MainActor + func testManagerLeavesRequestsCreatedBeforeAcceptanceManual() async throws { + let harness = AllowanceHarness() + let acceptedAt = "2026-09-24T11:30:00Z" + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: acceptedAt)]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:00:00Z"))) + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:29:40Z")), "Within the clock tolerance") + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:45:00Z"))) + } + + // MARK: Helpers + + /// Three succeeded automatic payments this month total 49,500 sats of the 50,000 sat cap. + private static func stateNearTheMonthlyCap() throws -> Paykit.AllowanceAccountingState { + try Fixtures.accountingState(occurrences: [ + Fixtures.occurrence(requestId: "paid-1", attempts: [ + Fixtures.attemptRecord(id: "paid-1", amount: "0.0002", admittedAt: "2026-09-05T10:00:00Z", status: .succeeded), + ]), + Fixtures.occurrence(requestId: "paid-2", attempts: [ + Fixtures.attemptRecord(id: "paid-2", amount: "0.0002", admittedAt: "2026-09-12T10:00:00Z", status: .succeeded), + ]), + Fixtures.occurrence(requestId: "paid-3", attempts: [ + Fixtures.attemptRecord(id: "paid-3", amount: "0.000095", admittedAt: "2026-09-20T10:00:00Z", status: .succeeded), + ]), + ]) + } + + private static func journalEntry( + attemptId: String, + request: PaykitPaymentRequest, + stage: PaykitAllowanceLocalState.Stage, + paymentHash: String? = nil + ) -> PaykitAllowanceLocalState.JournalEntry { + PaykitAllowanceLocalState.JournalEntry( + attemptId: attemptId, + isAutomatic: true, + requestId: request.id, + allowanceId: Fixtures.walletAllowanceId, + amountSats: request.amountSats, + paymentEndpointIdentifier: Fixtures.lightningIdentifier, + paymentHash: paymentHash, + onchainAddress: nil, + transactionId: nil, + stage: stage, + createdAt: Fixtures.now + ) + } +} + +// MARK: - Test doubles + +private struct AllowanceHarness { + static let paymentHash = String(repeating: "ab", count: 32) + static let invoice = "lnbcrt10u1allowancetestinvoice" + + let log = AllowanceCallLog() + let store = AllowanceMemoryStore() + let clock = AllowanceTestClock(PaykitAllowanceFixtures.now) + let sdk: AllowanceSdkMock + let payer: AllowancePayerMock + let lookup: AllowanceLightningLookupMock + let executor: PaykitAllowanceExecutor + + init() { + sdk = AllowanceSdkMock(log: log) + payer = AllowancePayerMock(log: log, payment: Self.lightningPayment()) + lookup = AllowanceLightningLookupMock(log: log) + let clock = clock + executor = PaykitAllowanceExecutor(sdk: sdk, store: store, payer: payer, lightningLookup: lookup, now: { clock.now() }) + } + + static func candidate(blocked: Paykit.AllowanceAccountingBlock? = nil) -> Paykit.AllowanceCandidate { + Paykit.AllowanceCandidate( + allowanceId: PaykitAllowanceFixtures.walletAllowanceId, + eligiblePaymentEndpointIdentifiers: [PaykitAllowanceFixtures.lightningIdentifier], + blocked: blocked + ) + } + + static func lightningPayment() -> PrivatePaykitAllowancePayment { + PrivatePaykitAllowancePayment( + endpoint: PublicPaykitService.Endpoint( + methodId: .bitcoinLightningBolt11, + value: invoice, + min: nil, + max: nil, + rawPayload: "{\"value\":\"\(invoice)\"}" + ), + context: PrivatePaykitPaymentContext(receiverPath: PaykitReceiverPath.wallet, paymentListVersion: 3), + lightningPaymentHash: paymentHash, + lightningInvoiceHasAmount: true + ) + } +} + +private final class AllowanceCallLog: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + var entries: [String] { + lock.withLock { storage } + } + + func append(_ entry: String) { + lock.withLock { storage.append(entry) } + } +} + +private final class AllowanceTestClock: @unchecked Sendable { + private let lock = NSLock() + private var date: Date + + init(_ date: Date) { + self.date = date + } + + func now() -> Date { + lock.withLock { date } + } + + func set(_ newDate: Date) { + lock.withLock { date = newDate } + } +} + +private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sendable { + private let lock = NSLock() + private var states: [String: PaykitAllowanceLocalState] = [:] + + func load(identity: String) throws -> PaykitAllowanceLocalState { + lock.withLock { states[identity] ?? PaykitAllowanceLocalState() } + } + + func save(_ state: PaykitAllowanceLocalState, identity: String) throws { + lock.withLock { states[identity] = state } + } + + func seed(_ state: PaykitAllowanceLocalState, identity: String) { + lock.withLock { states[identity] = state } + } +} + +private final class AllowanceEventRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [PaykitAllowanceEvent] = [] + private var cancellable: AnyCancellable? + + init() { + cancellable = PaykitAllowanceExecutor.eventPublisher.sink { [weak self] event in + self?.append(event) + } + } + + var events: [PaykitAllowanceEvent] { + lock.withLock { storage } + } + + private func append(_ event: PaykitAllowanceEvent) { + lock.withLock { storage.append(event) } + } +} + +private enum AllowanceMockError: Error { + case unsupported +} + +private actor AllowanceLightningLookupMock: PaykitLightningPaymentProofLookingUp { + private let log: AllowanceCallLog + private var statuses: [String: PaykitLightningPaymentProofStatus] = [:] + + init(log: AllowanceCallLog) { + self.log = log + } + + func setStatuses(_ statuses: [String: PaykitLightningPaymentProofStatus]) { + self.statuses = statuses + } + + func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { + log.append("lightningStatus") + return statuses[paymentHash.lowercased()] ?? .unknown + } +} + +private actor AllowancePayerMock: PaykitAllowancePaying { + struct PreparedProof: Equatable { + let endpoint: String + let allowanceId: String? + } + + struct LightningPayment: Equatable { + let bolt11: String + let sats: UInt64? + } + + private let log: AllowanceCallLog + private let payment: PrivatePaykitAllowancePayment? + private var resolveError: Error? + private(set) var callCount = 0 + private(set) var preparedProofs: [PreparedProof] = [] + private(set) var associatedPaymentHashes: [String] = [] + private(set) var lightningPayments: [LightningPayment] = [] + + init(log: AllowanceCallLog, payment: PrivatePaykitAllowancePayment?) { + self.log = log + self.payment = payment + } + + private func called(_ name: String) { + callCount += 1 + log.append(name) + } + + func setResolveError(_ error: Error?) { + resolveError = error + } + + func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? { + called("resolve") + if let resolveError { throw resolveError } + return payment + } + + func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext) async throws { + called("consumePaymentList") + } + + func prepareProof(_ request: PaykitPaymentRequest, paymentEndpointIdentifier: String, allowanceId: String?) async throws { + called("prepareProof") + preparedProofs.append(PreparedProof(endpoint: paymentEndpointIdentifier, allowanceId: allowanceId)) + } + + func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + called("associateLightningPayment") + associatedPaymentHashes.append(paymentHash) + } + + func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String) async throws { + called("markOnchainPaymentStarted") + } + + func payLightning(bolt11: String, sats: UInt64?) async throws { + called("payLightning") + lightningPayments.append(LightningPayment(bolt11: bolt11, sats: sats)) + } + + func payOnchain(address: String, sats: UInt64) async throws -> String { + called("payOnchain") + return String(repeating: "c", count: 64) + } + + func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String) async { + called("completeOnchainPayment") + } + + func failLightningPayment(paymentHash: String) async { + called("failLightningPayment") + } + + func cancelProofPreparation(_ request: PaykitPaymentRequest) async { + called("cancelProofPreparation") + } +} + +private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { + static let automaticAttemptId = "attempt-1" + static let manualAttemptId = "manual-1" + + private let log: AllowanceCallLog + private var records: [Paykit.AllowanceRecord] = [] + private var accountingState: Paykit.AllowanceAccountingState? = PaykitAllowanceFixtures.accountingState() + private var candidates: [Paykit.AllowanceCandidate] = [AllowanceHarness.candidate()] + private var automaticReservation: Paykit.PaymentAttemptDecision? + private var manualReservation: Paykit.PaymentAttemptDecision? + private var beginDecision: Paykit.PaymentAttemptDecision? + private(set) var evaluatedTrustedTimes: [String] = [] + private(set) var selections: [Paykit.AllowanceSelectionInput] = [] + private(set) var acceptedEndpointIdentifiers: [String] = [] + private(set) var reservedAssociationRevisions: [UInt64] = [] + private(set) var recordedOutcomes: [Paykit.PaymentOutcomeReport] = [] + private(set) var reconciliations: [Paykit.AllowanceAccountingReconciliation] = [] + private(set) var manualOnlyOccurrences: [Paykit.PaymentOccurrence] = [] + + init(log: AllowanceCallLog) { + self.log = log + } + + func setRecords(_ records: [Paykit.AllowanceRecord]) { + self.records = records + } + + func setAccountingState(_ state: Paykit.AllowanceAccountingState?) { + accountingState = state + } + + func setCandidates(_ candidates: [Paykit.AllowanceCandidate]) { + self.candidates = candidates + } + + func setAutomaticReservation(_ decision: Paykit.PaymentAttemptDecision) { + automaticReservation = decision + } + + func setManualReservation(_ decision: Paykit.PaymentAttemptDecision) { + manualReservation = decision + } + + func setBeginDecision(_ decision: Paykit.PaymentAttemptDecision) { + beginDecision = decision + } + + func linkedPeers() async throws -> [LinkedPeerRecord] { + log.append("linkedPeers") + return [] + } + + func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] { + log.append("listAllowances") + return records + } + + func proposeAllowance( + counterparty: String, + counterpartyReceiverPath: String, + localRole: Paykit.AllowanceLocalRole, + terms: Paykit.AllowanceTerms + ) async throws -> Paykit.AllowanceRecord { + log.append("proposeAllowance") + throw AllowanceMockError.unsupported + } + + func acceptAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + log.append("acceptAllowance") + throw AllowanceMockError.unsupported + } + + func rejectAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + log.append("rejectAllowance") + throw AllowanceMockError.unsupported + } + + func endAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + log.append("endAllowance") + throw AllowanceMockError.unsupported + } + + func receivePrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.PrivateStreamIntakeReport { + log.append("receivePrivateMessages") + return Paykit.PrivateStreamIntakeReport(receiveBatchId: nil, streamItemIds: [], eventConflicts: []) + } + + func processOutboundPrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.OutboundPrivateSendReport { + log.append("processOutboundPrivateMessages") + return Paykit.OutboundPrivateSendReport(attempted: [], sent: [], failed: [], reservationCleanupFailures: [], recoveryMarkerFailures: []) + } + + func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? { + log.append("allowanceAccountingState") + return accountingState + } + + func reconcileAllowanceAccounting(_ reconciliation: Paykit.AllowanceAccountingReconciliation) async throws -> Paykit.AllowanceAccountingState { + log.append("reconcileAllowanceAccounting") + reconciliations.append(reconciliation) + let reconciled = Paykit.AllowanceAccountingState( + revision: (reconciliation.expectedRevision ?? 0) + 1, + epoch: accountingState?.epoch ?? "epoch-1", + requiresReconciliation: false, + history: reconciliation.history + ) + accountingState = reconciled + return reconciled + } + + func evaluateAllowanceCandidates(scope: Paykit.PaymentRequestScope, trustedTime: String) async throws -> [Paykit.AllowanceCandidate] { + log.append("evaluateAllowanceCandidates") + evaluatedTrustedTimes.append(trustedTime) + return candidates + } + + func acceptPaymentRequestAutomatically( + scope: Paykit.PaymentRequestScope, + selection: Paykit.AllowanceSelectionInput, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.AllowanceAssociationRecord { + log.append("acceptPaymentRequestAutomatically") + selections.append(selection) + acceptedEndpointIdentifiers.append(checks.paymentEndpointIdentifier) + return Paykit.AllowanceAssociationRecord( + request: PaykitAllowanceFixtures.accountingScope(scope.paymentRequestId), + revisions: [ + Paykit.AllowanceAssociationRevision( + revision: 1, + allowanceId: selection.allowanceId, + effectiveFrom: nil, + authorizationId: nil, + authorizedAt: selection.trustedTime + ), + ] + ) + } + + func reserveAutomaticPayment( + occurrence: Paykit.PaymentOccurrence, + expectedAssociationRevision: UInt64, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.PaymentAttemptDecision { + log.append("reserveAutomaticPayment") + reservedAssociationRevisions.append(expectedAssociationRevision) + if let automaticReservation { + return automaticReservation + } + return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord( + id: Self.automaticAttemptId, + admittedAt: checks.trustedTime, + status: .prepared + )) + } + + func reserveManualPayment(occurrence: Paykit.PaymentOccurrence, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit + .PaymentAttemptDecision + { + log.append("reserveManualPayment") + if let manualReservation { + return manualReservation + } + return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord( + id: Self.manualAttemptId, + mode: .manual, + allowanceId: nil, + admittedAt: checks.trustedTime, + status: .prepared + )) + } + + func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision { + log.append("beginPaymentExecution") + if let beginDecision { + return beginDecision + } + return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord(id: attemptId, admittedAt: checks.trustedTime, status: .submitted)) + } + + func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord { + log.append("recordPaymentOutcome") + recordedOutcomes.append(report) + let status: Paykit.PaymentExecutionStatus = switch report.outcome { + case .succeeded: .succeeded + case .failed: .failed + case .unknown: .unknown + } + return try PaykitAllowanceFixtures.attemptRecord(id: report.attemptId, status: status) + } + + func markPaymentManualOnly(occurrence: Paykit.PaymentOccurrence) async throws -> Paykit.PaymentOccurrenceRecord { + log.append("markPaymentManualOnly") + manualOnlyOccurrences.append(occurrence) + return Paykit.PaymentOccurrenceRecord( + key: Paykit.PaymentOccurrenceKey( + request: PaykitAllowanceFixtures.accountingScope(occurrence.request.paymentRequestId), + billingPeriod: nil + ), + disposition: .manualOnly, + allowanceId: nil, + associationRevision: nil, + attempts: [] + ) + } +} diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift new file mode 100644 index 000000000..bd07e94a2 --- /dev/null +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -0,0 +1,564 @@ +@testable import Bitkit +import Foundation +import LDKNode +import Paykit +import XCTest + +final class PaykitAllowanceTests: XCTestCase { + private typealias Fixtures = PaykitAllowanceFixtures + + // MARK: Terms and records + + func testTermsCarryPerPaymentRangeAnchoredUtcMonthAndAllowlist() throws { + let monthAnchor = PaykitAllowanceTime.monthStart(containing: Fixtures.now) + let allowlist = [Fixtures.lightningIdentifier, Fixtures.onchainIdentifier] + + let terms = try Fixtures.limits.terms(monthAnchor: monthAnchor, allowedPaymentEndpointIdentifiers: allowlist) + + XCTAssertEqual(terms.asset(), PaykitIssuerInterop.bitcoinAsset) + let perPayment = try XCTUnwrap(terms.perPaymentAmount()) + XCTAssertEqual(perPayment.minimum(), "0") + XCTAssertEqual(perPayment.maximum(), "0.00005") + XCTAssertEqual(terms.periodLimits().count, 1) + let monthly = try XCTUnwrap(terms.periodLimits().first) + XCTAssertEqual(monthly.amountLimit(), "0.0005") + XCTAssertNil(monthly.paymentCountLimit()) + XCTAssertEqual(monthly.period().kind(), "anchored") + XCTAssertEqual(monthly.period().every(), 1) + XCTAssertEqual(monthly.period().unit(), "month") + XCTAssertTrue(PaykitAllowance.isMonthly(monthly.period())) + XCTAssertEqual(monthly.period().anchor(), "2026-09-01T00:00:00.000Z") + XCTAssertEqual(monthly.period().anchor().flatMap(PaykitAllowanceTime.parse), Fixtures.septemberAnchor) + XCTAssertNil(terms.lifetimeAmountLimit()) + XCTAssertNil(terms.activeFrom()) + XCTAssertNil(terms.expiresAt()) + XCTAssertEqual(terms.allowedPaymentEndpointIdentifiers(), allowlist) + } + + func testRecordReadsBackSatsAnchorRoleAndAllowlist() throws { + let allowlist = [Fixtures.lightningIdentifier, Fixtures.onchainIdentifier] + let terms = try Fixtures.limits.terms(monthAnchor: Fixtures.septemberAnchor, allowedPaymentEndpointIdentifiers: allowlist) + let record = Fixtures.record(state: .proposed, terms: terms, proposedByMe: true) + + let allowance = try XCTUnwrap(PaykitAllowance(record: record)) + + XCTAssertEqual(allowance.counterparty, Fixtures.counterpartyKey) + XCTAssertEqual(allowance.counterpartyReceiverPath, PaykitReceiverPath.wallet) + XCTAssertEqual(allowance.allowanceId, Fixtures.walletAllowanceId) + XCTAssertEqual(allowance.role, .allower) + XCTAssertTrue(allowance.isAllower) + XCTAssertTrue(allowance.isProposedByMe) + XCTAssertEqual(allowance.lifecycleState, .proposed) + XCTAssertEqual(allowance.perPaymentMaxSats, 5000) + XCTAssertEqual(allowance.monthlyLimitSats, 50000) + XCTAssertEqual(allowance.monthlyAnchor, Fixtures.septemberAnchor) + XCTAssertNil(allowance.activeFrom) + XCTAssertNil(allowance.expiresAt) + XCTAssertEqual(allowance.allowedPaymentEndpointIdentifiers, allowlist) + XCTAssertEqual(allowance.lastEventAt, Fixtures.utc("2026-09-02T10:00:00Z")) + + let receivedRecord = Fixtures.record(localRole: .allowee, state: .proposed, terms: terms, proposedByMe: false) + let received = try XCTUnwrap(PaykitAllowance(record: receivedRecord)) + XCTAssertEqual(received.role, .allowee) + XCTAssertFalse(received.isAllower) + XCTAssertFalse(received.isProposedByMe) + XCTAssertTrue(received.isAnswerable) + } + + func testRecordWithoutUsableRoleOrTermsIsSkipped() throws { + let terms = try Fixtures.standardTerms() + + XCTAssertNil(PaykitAllowance(record: Fixtures.record(localRole: nil, terms: terms))) + XCTAssertNil(PaykitAllowance(record: Fixtures.record(localRole: .unknown, terms: terms))) + XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: nil))) + } + + func testStatusMapsEveryLifecycleState() throws { + func status( + _ state: Paykit.AllowanceLifecycleState, + proposedByMe: Bool = true, + terms: Paykit.AllowanceTerms? = nil, + at date: Date = PaykitAllowanceFixtures.now + ) throws -> PaykitAllowance.Status { + let resolvedTerms = try terms ?? Fixtures.standardTerms() + let record = Fixtures.record(state: state, terms: resolvedTerms, proposedByMe: proposedByMe) + return try XCTUnwrap(PaykitAllowance(record: record)).status(at: date) + } + + XCTAssertEqual(try status(.proposed, proposedByMe: true), .awaitingAnswer) + XCTAssertEqual(try status(.proposed, proposedByMe: false), .awaitingMyAnswer) + XCTAssertEqual(try status(.accepted), .active) + XCTAssertEqual(try status(.rejected), .declined) + XCTAssertEqual(try status(.ended), .ended) + XCTAssertEqual(try status(.conflicted), .conflicted) + XCTAssertEqual(try status(.unknown), .conflicted) + + let expiry = Fixtures.utc("2026-09-20T00:00:00Z") + let expiring = try Fixtures.customTerms(expiresAt: expiry) + XCTAssertEqual(try status(.accepted, terms: expiring, at: expiry.addingTimeInterval(-1)), .active) + XCTAssertEqual(try status(.accepted, terms: expiring, at: expiry), .expired) + XCTAssertEqual(try status(.accepted, terms: expiring, at: Fixtures.now), .expired) + + let start = Fixtures.utc("2026-10-01T00:00:00Z") + let scheduled = try Fixtures.customTerms(activeFrom: start) + XCTAssertEqual(try status(.accepted, terms: scheduled, at: Fixtures.now), .notYetActive) + XCTAssertEqual(try status(.accepted, terms: scheduled, at: start), .active) + } + + func testSatsFromBitcoinAmountReadsTheZeroMinimum() { + XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0"), 0) + XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0.00000000"), 0) + XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0.00005"), 5000) + XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0.0005"), 50000) + XCTAssertNil(PaykitAllowance.sats(fromBitcoinAmount: "abc")) + } + + // MARK: Monthly window + + func testMonthStartUsesTheUtcCalendarMonth() { + XCTAssertEqual(PaykitAllowanceTime.monthStart(containing: Fixtures.now), Fixtures.septemberAnchor) + XCTAssertEqual(PaykitAllowanceTime.monthStart(containing: Fixtures.utc("2026-10-01T01:00:00+02:00")), Fixtures.septemberAnchor) + XCTAssertEqual( + PaykitAllowanceTime.monthStart(containing: Fixtures.utc("2026-09-30T23:30:00-02:00")), + Fixtures.utc("2026-10-01T00:00:00Z") + ) + } + + func testMonthlyWindowFromFirstOfMonthAnchor() { + let anchor = Fixtures.septemberAnchor + let cases: [(date: String, start: String, end: String)] = [ + ("2026-09-01T00:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + ("2026-09-24T12:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + ("2026-09-30T23:59:59Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + ("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z", "2026-11-01T00:00:00Z"), + ("2026-12-31T23:59:59Z", "2026-12-01T00:00:00Z", "2027-01-01T00:00:00Z"), + ("2027-02-10T08:00:00Z", "2027-02-01T00:00:00Z", "2027-03-01T00:00:00Z"), + ("2026-08-31T23:59:59Z", "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z"), + ("2026-07-15T12:00:00Z", "2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"), + ] + + for testCase in cases { + let window = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc(testCase.date)) + XCTAssertEqual(window.start, Fixtures.utc(testCase.start), "start for \(testCase.date)") + XCTAssertEqual(window.end, Fixtures.utc(testCase.end), "end for \(testCase.date)") + } + } + + func testMonthlyWindowClampsJanuaryThirtyFirstAnchorInFebruary() { + let anchor = Fixtures.utc("2026-01-31T12:30:00Z") + + let midFebruary = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc("2026-02-15T00:00:00Z")) + XCTAssertEqual(midFebruary.start, anchor) + XCTAssertEqual(midFebruary.end, Fixtures.utc("2026-02-28T12:30:00Z")) + + let lateFebruary = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc("2026-02-28T12:30:00Z")) + XCTAssertEqual(lateFebruary.start, Fixtures.utc("2026-02-28T12:30:00Z")) + } + + /// Vectors from paykit-lib `test_anchored_months_clamp_from_original_anchor`: every boundary is counted from the + /// original anchor, so the window after a clamped February still ends on March 31. + func testMonthlyWindowAfterClampedFebruaryMatchesPaykitAnchoredArithmetic() { + let anchor = Fixtures.utc("2026-01-31T12:30:00Z") + let vectors: [(date: String, start: String, end: String)] = [ + ("2026-02-28T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + ("2026-03-30T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + ("2025-12-01T00:00:00Z", "2025-11-30T12:30:00Z", "2025-12-31T12:30:00Z"), + ] + let marchAnchor = Fixtures.utc("2026-03-31T00:00:00Z") + let beforeMarchAnchor = PaykitAllowanceTime.monthlyWindow(anchor: marchAnchor, containing: Fixtures.utc("2026-01-15T00:00:00Z")) + let allowance = Fixtures.allowance(monthlyAnchor: anchor) + let lateMarchAttempt = PaykitAllowanceCapacity.Attempt( + allowanceId: allowance.allowanceId, + amountSats: 50000, + admittedAt: Fixtures.utc("2026-03-29T09:00:00Z"), + isLive: true + ) + + for vector in vectors { + let window = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc(vector.date)) + XCTAssertEqual(window.start, Fixtures.utc(vector.start), "start for \(vector.date)") + XCTAssertEqual(window.end, Fixtures.utc(vector.end), "end for \(vector.date)") + } + XCTAssertEqual(beforeMarchAnchor.start, Fixtures.utc("2025-12-31T00:00:00Z"), "start before a March 31 anchor") + XCTAssertEqual(beforeMarchAnchor.end, Fixtures.utc("2026-01-31T00:00:00Z"), "end before a March 31 anchor") + XCTAssertFalse( + PaykitAllowanceCapacity.fits( + amountSats: 1000, + allowance: allowance, + attempts: [lateMarchAttempt], + now: Fixtures.utc("2026-03-30T12:30:00Z") + ), + "A March 29 attempt at the cap must count on March 30" + ) + } + + // MARK: Capacity + + func testCapacityFitsUnderTheCap() { + let attempts = [Fixtures.capacityAttempt(sats: 20000, at: "2026-09-05T10:00:00Z")] + + XCTAssertEqual(Fixtures.usedSats(attempts), 20000) + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testCapacityFitsExactlyAtTheCap() { + let attempts = [ + Fixtures.capacityAttempt(sats: 20000, at: "2026-09-05T10:00:00Z"), + Fixtures.capacityAttempt(sats: 25000, at: "2026-09-12T10:00:00Z"), + ] + + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testCapacityRejectsOverTheMonthlyCap() { + let attempts = [ + Fixtures.capacityAttempt(sats: 20000, at: "2026-09-05T10:00:00Z"), + Fixtures.capacityAttempt(sats: 25000, at: "2026-09-12T10:00:00Z"), + ] + + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5001, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testCapacityRejectsOverThePerPaymentMaximum() { + let allowance = Fixtures.allowance() + + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: allowance, attempts: [], now: Fixtures.now)) + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5001, allowance: allowance, attempts: [], now: Fixtures.now)) + } + + func testCapacityIgnoresFailedAttempts() { + let attempts = [ + Fixtures.capacityAttempt(sats: 45000, at: "2026-09-05T10:00:00Z", isLive: false), + Fixtures.capacityAttempt(sats: 10000, at: "2026-09-12T10:00:00Z"), + ] + + XCTAssertEqual(Fixtures.usedSats(attempts), 10000) + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testCapacityIgnoresPreviousMonthAndOtherAllowanceAttempts() { + let attempts = [ + Fixtures.capacityAttempt(sats: 50000, at: "2026-08-31T23:59:59Z"), + Fixtures.capacityAttempt(allowanceId: Fixtures.serverAllowanceId, sats: 50000, at: "2026-09-10T10:00:00Z"), + Fixtures.capacityAttempt(sats: 1000, at: "2026-09-01T00:00:00Z"), + ] + + XCTAssertEqual(Fixtures.usedSats(attempts), 1000) + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testCapacityWithoutMonthlyLimitChecksOnlyThePerPaymentMaximum() { + let allowance = Fixtures.allowance(monthlyLimitSats: nil) + let attempts = [Fixtures.capacityAttempt(sats: 1_000_000, at: "2026-09-05T10:00:00Z")] + + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: allowance, attempts: attempts, now: Fixtures.now)) + } + + func testAttemptsFromHistoryKeepAutomaticAllowanceAttempts() throws { + let history = try Paykit.AllowanceAccountingHistory( + associations: [], + occurrences: [ + Fixtures.occurrence(requestId: "req-1", attempts: [ + Fixtures.attemptRecord(id: "failed", amount: "0.0001", admittedAt: "2026-09-02T10:00:00Z", status: .failed), + Fixtures.attemptRecord(id: "paid", amount: "0.0001", admittedAt: "2026-09-03T10:00:00Z", status: .succeeded), + ]), + Fixtures.occurrence(requestId: "req-2", attempts: [ + Fixtures.attemptRecord(id: "manual", mode: .manual, allowanceId: nil, admittedAt: "2026-09-04T10:00:00Z", status: .succeeded), + Fixtures.attemptRecord(id: "unattributed", allowanceId: nil, admittedAt: "2026-09-04T11:00:00Z", status: .succeeded), + Fixtures.attemptRecord(id: "open", amount: "0.00002", admittedAt: "2026-09-05T10:00:00Z", status: .submitted), + ]), + ], + watermarks: [] + ) + + let attempts = PaykitAllowanceCapacity.attempts(from: history) + + XCTAssertEqual(attempts, [ + PaykitAllowanceCapacity.Attempt( + allowanceId: Fixtures.walletAllowanceId, + amountSats: 10000, + admittedAt: Fixtures.utc("2026-09-02T10:00:00Z"), + isLive: false + ), + PaykitAllowanceCapacity.Attempt( + allowanceId: Fixtures.walletAllowanceId, + amountSats: 10000, + admittedAt: Fixtures.utc("2026-09-03T10:00:00Z"), + isLive: true + ), + PaykitAllowanceCapacity.Attempt( + allowanceId: Fixtures.walletAllowanceId, + amountSats: 2000, + admittedAt: Fixtures.utc("2026-09-05T10:00:00Z"), + isLive: true + ), + ]) + } + + // MARK: Trusted time vs demo clock + + func testStatusAndCapacityUseInjectedTimeWhileDemoClockIsOffset() throws { + snapshotAppDefaults(DemoClock.offsetDaysKey) + UserDefaults.standard.set(365, forKey: DemoClock.offsetDaysKey) + try XCTSkipUnless(DemoClock.offsetDays() == 365, "The demo clock is unavailable in this build") + let realNow = Date() + XCTAssertGreaterThan(DemoClock.subscriptionNow(), realNow.addingTimeInterval(364 * 24 * 60 * 60)) + + let allowance = Fixtures.allowance(expiresAt: Fixtures.now.addingTimeInterval(30 * 24 * 60 * 60)) + XCTAssertEqual(allowance.status(at: Fixtures.now), .active) + + let attempts = [Fixtures.capacityAttempt(sats: 50000, at: "2026-09-10T10:00:00Z")] + XCTAssertEqual(Fixtures.usedSats(attempts), 50000) + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 1, allowance: allowance, attempts: attempts, now: Fixtures.now)) + } + + // MARK: Grouping + + @MainActor + func testOrderedReceiverPathsPutTheWalletLinkFirst() { + XCTAssertEqual( + PaykitAllowanceManager.orderedReceiverPaths([ + PaykitReceiverPath.server, + "a/other", + PaykitReceiverPath.wallet, + PaykitReceiverPath.server, + ]), + [PaykitReceiverPath.wallet, "a/other", PaykitReceiverPath.server] + ) + XCTAssertEqual(PaykitAllowanceManager.orderedReceiverPaths([PaykitReceiverPath.server]), [PaykitReceiverPath.server]) + XCTAssertEqual(PaykitAllowanceManager.orderedReceiverPaths([]), []) + } + + func testEntryPrimaryPrefersTheWalletLink() { + let server = Fixtures.allowance(allowanceId: Fixtures.serverAllowanceId, receiverPath: PaykitReceiverPath.server, perPaymentMaxSats: 1) + let wallet = Fixtures.allowance(allowanceId: Fixtures.walletAllowanceId, receiverPath: PaykitReceiverPath.wallet) + + let entry = PaykitAllowanceEntry(id: "group", allowances: [server, wallet], limits: Fixtures.limits) + XCTAssertEqual(entry.primary.allowanceId, Fixtures.walletAllowanceId) + XCTAssertEqual(entry.perPaymentMaxSats, 5000) + + let serverOnly = PaykitAllowanceEntry(id: "server", allowances: [server], limits: nil) + XCTAssertEqual(serverOnly.primary.allowanceId, Fixtures.serverAllowanceId) + } +} + +/// Shared builders for the Allowance suites. +enum PaykitAllowanceFixtures { + static let identityKey = "pubky\(String(repeating: "z", count: 52))" + static let counterpartyKey = "pubky\(String(repeating: "y", count: 52))" + static let otherCounterpartyKey = "pubky\(String(repeating: "x", count: 52))" + static let walletAllowanceId = "allowance-wallet" + static let serverAllowanceId = "allowance-server" + static let lightningIdentifier = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + static let onchainIdentifier = PublicPaykitService.MethodId.bitcoinOnchainP2wpkh.rawValue + static let now = utc("2026-09-24T12:00:00Z") + static let septemberAnchor = utc("2026-09-01T00:00:00Z") + static let limits = PaykitAllowanceLimits(perPaymentUsd: 5, monthlyUsd: 50, perPaymentSats: 5000, monthlySats: 50000) + + static func utc(_ value: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: value) else { + preconditionFailure("Invalid fixture timestamp \(value)") + } + return date + } + + static func standardTerms() throws -> Paykit.AllowanceTerms { + try limits.terms(monthAnchor: septemberAnchor, allowedPaymentEndpointIdentifiers: [lightningIdentifier]) + } + + static func customTerms(activeFrom: Date? = nil, expiresAt: Date? = nil) throws -> Paykit.AllowanceTerms { + try Paykit.AllowanceTerms( + asset: PaykitIssuerInterop.bitcoinAsset, + perPaymentAmount: Paykit.AllowanceAmountRange(minimum: "0", maximum: "0.00005"), + periodLimits: [ + Paykit.AllowancePeriodLimit( + amountLimit: "0.0005", + paymentCountLimit: nil, + period: Paykit.AllowancePeriod(kind: "anchored", every: 1, unit: "month", anchor: "2026-09-01T00:00:00Z") + ), + ], + lifetimeAmountLimit: nil, + activeFrom: activeFrom.map(PaykitAllowanceTime.format), + expiresAt: expiresAt.map(PaykitAllowanceTime.format), + allowedPaymentEndpointIdentifiers: [lightningIdentifier] + ) + } + + static func record( + allowanceId: String = walletAllowanceId, + counterparty: String = counterpartyKey, + receiverPath: String = PaykitReceiverPath.wallet, + localRole: Paykit.AllowanceLocalRole? = .allower, + state: Paykit.AllowanceLifecycleState = .accepted, + historyStatus: Paykit.AllowanceHistoryStatus = .consistent, + terms: Paykit.AllowanceTerms?, + proposedByMe: Bool = true, + lastEventAt: String? = "2026-09-02T10:00:00Z" + ) -> Paykit.AllowanceRecord { + Paykit.AllowanceRecord( + counterparty: counterparty, + counterpartyReceiverPath: receiverPath, + allowanceId: allowanceId, + localRole: localRole, + state: state, + historyStatus: historyStatus, + proposalEventId: "proposal-\(allowanceId)", + terms: terms, + proposalStreamItemId: proposedByMe ? nil : 1, + proposalOutboundMessageId: proposedByMe ? 1 : nil, + proposalOutboundStatus: nil, + acceptanceEventId: nil, + acceptanceOutboundStatus: nil, + rejectionEventId: nil, + rejectionOutboundStatus: nil, + endEventId: nil, + endOutboundStatus: nil, + pendingCausalEventIds: [], + conflictEventIds: [], + lastStreamItemId: nil, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: lastEventAt, + invalidReason: nil + ) + } + + static func allowance( + allowanceId: String = walletAllowanceId, + counterparty: String = counterpartyKey, + receiverPath: String = PaykitReceiverPath.wallet, + role: PaykitAllowance.Role = .allower, + state: Paykit.AllowanceLifecycleState = .accepted, + perPaymentMaxSats: UInt64? = 5000, + monthlyLimitSats: UInt64? = 50000, + monthlyAnchor: Date? = septemberAnchor, + expiresAt: Date? = nil + ) -> PaykitAllowance { + PaykitAllowance( + id: PaykitAllowance.ID(counterparty: counterparty, counterpartyReceiverPath: receiverPath, allowanceId: allowanceId), + role: role, + lifecycleState: state, + isProposedByMe: role == .allower, + perPaymentMaxSats: perPaymentMaxSats, + monthlyLimitSats: monthlyLimitSats, + monthlyAnchor: monthlyAnchor, + expiresAt: expiresAt, + allowedPaymentEndpointIdentifiers: [lightningIdentifier] + ) + } + + static func capacityAttempt( + allowanceId: String = walletAllowanceId, + sats: UInt64, + at timestamp: String, + isLive: Bool = true + ) -> PaykitAllowanceCapacity.Attempt { + PaykitAllowanceCapacity.Attempt(allowanceId: allowanceId, amountSats: sats, admittedAt: utc(timestamp), isLive: isLive) + } + + static func usedSats(_ attempts: [PaykitAllowanceCapacity.Attempt]) -> UInt64 { + PaykitAllowanceCapacity.usedSats(allowanceId: walletAllowanceId, attempts: attempts, anchor: septemberAnchor, now: now) + } + + static func attemptRecord( + id: String, + mode: Paykit.PaymentExecutionMode = .automatic, + allowanceId: String? = walletAllowanceId, + amount: String = "0.00001", + admittedAt: String = "2026-09-24T12:00:00Z", + status: Paykit.PaymentExecutionStatus, + epoch: String = "epoch-1" + ) throws -> Paykit.PaymentAttemptRecord { + try Paykit.PaymentAttemptRecord( + attemptId: id, + mode: mode, + allowanceId: allowanceId, + associationRevision: allowanceId == nil ? nil : 1, + amount: Paykit.AccountingAmount(value: amount, asset: PaykitIssuerInterop.bitcoinAsset), + admittedAt: admittedAt, + status: status, + epoch: epoch + ) + } + + static func accountingScope(_ paymentRequestId: String) -> Paykit.PaymentAccountingScope { + Paykit.PaymentAccountingScope( + localPublicKey: identityKey, + localReceiverPath: PaykitReceiverPath.wallet, + counterparty: counterpartyKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + paymentRequestId: paymentRequestId + ) + } + + static func occurrence( + requestId: String, + attempts: [Paykit.PaymentAttemptRecord], + allowanceId: String? = walletAllowanceId + ) -> Paykit.PaymentOccurrenceRecord { + Paykit.PaymentOccurrenceRecord( + key: Paykit.PaymentOccurrenceKey(request: accountingScope(requestId), billingPeriod: nil), + disposition: .automatic, + allowanceId: allowanceId, + associationRevision: allowanceId == nil ? nil : 1, + attempts: attempts + ) + } + + static func accountingState( + revision: UInt64 = 1, + epoch: String = "epoch-1", + requiresReconciliation: Bool = false, + occurrences: [Paykit.PaymentOccurrenceRecord] = [] + ) -> Paykit.AllowanceAccountingState { + Paykit.AllowanceAccountingState( + revision: revision, + epoch: epoch, + requiresReconciliation: requiresReconciliation, + history: Paykit.AllowanceAccountingHistory(associations: [], occurrences: occurrences, watermarks: []) + ) + } + + static func paymentRequest( + id: String = "550e8400-e29b-41d4-a716-446655440001", + counterparty: String = counterpartyKey, + receiverPath: String = PaykitReceiverPath.wallet, + amount: String = "0.00001", + createdAt: String = "2026-09-24T11:00:00Z" + ) throws -> PaykitPaymentRequest { + let record = try Paykit.PaymentRequestRecord( + counterparty: counterparty, + counterpartyReceiverPath: receiverPath, + paymentRequestId: id, + localRole: .payer, + state: .proposed, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + terms: Paykit.PaymentRequestTerms( + amount: Paykit.PaymentRequestAmount(value: amount, asset: PaykitIssuerInterop.bitcoinAsset), + paymentReference: Paykit.PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, + recurrence: nil, + acceptedPaymentEndpointIdentifiers: [lightningIdentifier], + metadata: Paykit.PrivateJsonObject(text: "{}") + ), + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + paymentProofs: [], + lastStreamItemId: 1, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: createdAt, + invalidReason: nil + ) + return try XCTUnwrap(PaykitPaymentRequest(record: record, now: now, network: .regtest)) + } +} diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index e80a275d5..93592b424 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -918,6 +918,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, paymentEndpointIdentifier: endpoint, + allowanceId: nil, proof: PrivateJsonObject(text: "{\"data\":\"\(data)\",\"type\":\"\(kind.rawValue)\"}"), recordedAt: "2027-01-15T08:01:00Z" ) @@ -1071,6 +1072,7 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { paymentReference: paymentReference, billingPeriod: proof.billingPeriod, paymentEndpointIdentifier: proof.paymentEndpointIdentifier, + allowanceId: proof.allowanceId, proof: proof.proof, recordedAt: "2027-01-15T08:01:00Z" )) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index a9bccf6dd..0076513b2 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3323,6 +3323,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, paymentEndpointIdentifier: endpoint, + allowanceId: nil, proof: PrivateJsonObject(text: "{\"data\":\"proof\",\"type\":\"\(kind.rawValue)\"}"), recordedAt: "2027-01-15T08:01:00Z" ) diff --git a/changelog.d/next/799.added.md b/changelog.d/next/799.added.md new file mode 100644 index 000000000..6e89aeb68 --- /dev/null +++ b/changelog.d/next/799.added.md @@ -0,0 +1 @@ +Allowances: set a per-payment and a monthly limit for a Paykit contact so their payment requests within the limits are paid automatically. diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md new file mode 100644 index 000000000..67dcaed48 --- /dev/null +++ b/journeys/allowances/README.md @@ -0,0 +1,54 @@ +# Allowances journeys + +Cover the Paykit allowance lifecycle between two Bitkit instances: the payer sets an allowance for a +contact, the payee accepts it, requests within the limits are paid without asking, a request above a +limit falls back to the normal Payment Request sheet, and either side ends it. The restart journey +pins the one rule that must never break: a payment interrupted mid-flight is never paid twice. + +## Setup + +Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as +contacts and linked on receiver path `bitkit/wallet`, exactly as for +[`../subscriptions/README.md`](../subscriptions/README.md). One instance plays the payer (the one +that sets the allowance), the other the payee (the one that sends requests). Automatic payments go +over Lightning only, so the payer needs a spending balance above 50,000 sats with a usable channel, +and the payee needs receiving capacity; fund both through the staging LSP. + +Allow notifications for Bitkit on the payer when it asks: the "Payment Executed" and "Limit +Reached" events post a local notification when they can, and fall back to an in-app toast that the +UI snapshot cannot see. + +The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey +sets $5 a payment and $10 a month, the first monthly stop. Requests are one-time Payment Requests +created from the Payments tab of the payee, in the fiat unit. Dollar amounts on screen are converted +at the current rate, so a sats amount next to them will differ between runs. + +## Reference evidence + +The source run drove every journey on 2026-09-24 across two Android 16 emulators against the +staging regtest LSP, with Paykit built from pubky/paykit-rs#161, and the set, accept, auto-pay, +ask-above-limit and end flows again on two iOS simulators. A $2 and a $4 request were paid +about two seconds after arriving, a $20 request asked, the third $4 request on a $10 monthly cap +raised Limit Reached, ending the allowance from either side stopped automatic payments, and a payer +killed right after handing the payment to the node never paid twice after relaunch. + +## Identifiers used + +- Tab: `Tab-allowances`; empty state `AllowancesEmpty`; footer button `AllowanceAdd` +- Contact picker: `AllowanceContact-` +- Set sheet: `SetAllowance`, sliders `AllowancePerPayment` and `AllowanceMonthly` with stops + `AllowancePerPaymentStop-` and `AllowanceMonthlyStop-`, `AllowanceSummary`, + `AllowanceSave` +- List row: `AllowanceRow-` with its status line `AllowanceRowStatus` +- Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, + `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline` +- Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus` +- Payments tab: `Tab-payments`, `PaymentRequestRequestPayment`, + `PaymentRequestRow--one-time` whose subtitle ends with "· Auto-paid" for an + automatic payment +- Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet` + +The review sheet on the payee opens on its own about a second after the proposal arrives; no tap is +needed to reach it. The file names, journey names and step prose match +[`bitkit-android/journeys/allowances`](https://github.com/synonymdev/bitkit-android/tree/master/journeys/allowances); +only the identifier annotations and the kill, relaunch and notification mechanics differ. diff --git a/journeys/allowances/above-limit-asks.xml b/journeys/allowances/above-limit-asks.xml new file mode 100644 index 000000000..a24e7ff87 --- /dev/null +++ b/journeys/allowances/above-limit-asks.xml @@ -0,0 +1,19 @@ + + + A request above the per-payment limit is never paid automatically: it arrives as an ordinary + Payment Request that the payer pays by swiping, and a manual payment does not count toward the + amount paid automatically. Requires the Active $5 a payment allowance from set-and-accept.xml + and a payer balance above the request. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active and at least one automatic payment made, per auto-pay-under-limit.xml + On the payee instance, open Subscriptions, the Payments tab (id "Tab-payments"), tap Request Payment (id "PaymentRequestRequestPayment") and send the payer a one-time Payment Request for $20 with the note "Artpack" + Switch to the payer instance + Verify the Payment Request sheet (id "PaymentRequestsSheet") opens for $20.00 from the payee, or the bell (id "PaymentRequestsBell") shows one pending request, and that nothing was sent automatically + Verify no "Payment Executed" notification was posted for this request + Pay it from the sheet (id "PaymentRequestPay-<paymentRequestId>") and complete Swipe To Pay + Verify Bitcoin Sent shows about $20.00 + Open Subscriptions, tap the Payments tab and verify the "Artpack" row is listed without "· Auto-paid" in its subtitle + Tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (id "AllowancePaidSoFar") still shows only the automatic payments, not the $20 + + diff --git a/journeys/allowances/auto-pay-under-limit.xml b/journeys/allowances/auto-pay-under-limit.xml new file mode 100644 index 000000000..1a293c59b --- /dev/null +++ b/journeys/allowances/auto-pay-under-limit.xml @@ -0,0 +1,22 @@ + + + A request within both limits is paid without the payer seeing a sheet. The payer only gets a + "Payment Executed" notification, the payee receives the payment, and the payer's Payments tab and + allowance detail both account for it. Requires the Active allowance from set-and-accept.xml and + notification permission granted on the payer. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance from set-and-accept.xml Active, and the payer's notification permission granted + On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (id "Tab-payments") and tap Request Payment (id "PaymentRequestRequestPayment") + Send the payer a one-time Payment Request for $2 with the note "Devlog" + Switch to the payer instance, with Bitkit in the foreground on the home screen + Verify that within about five seconds no Payment Request sheet opens and the bell (id "PaymentRequestsBell") shows no pending request + Verify a "Payment Executed" notification with the text "Auto-pay sent $2.00 to <payee>" is posted as a banner, or under Bitkit in Notification Center; with notifications not allowed it is an in-app toast + Switch to the payee instance and verify the payment arrives, either as the Received Instant Bitcoin sheet or as a $2.00 "Received from <payer>" row in Activity + Switch to the payer instance, open the drawer menu, tap Subscriptions and tap the Payments tab + Verify the "Devlog" row (id "PaymentRequestRow-<paymentRequestId>-one-time") is listed with a subtitle ending in "· Auto-paid" + Tap the Allowances tab, then tap the payee's row + Verify the detail sheet (id "AllowanceDetail") shows PAID AUTOMATICALLY "$2.00" (id "AllowancePaidSoFar") and the explanation "Requests within these limits are paid automatically. Anything above them asks you first." (id "AllowanceDetailStatus") + Open Activity from the home screen and verify the newest row reads "Sent to <payee>" for $2.00 + + diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml new file mode 100644 index 000000000..f3003d79f --- /dev/null +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -0,0 +1,26 @@ + + + Either side can end an allowance from its detail sheet, and from then on every request asks + again. The first half ends it on the payer; the second half sets a fresh allowance and ends it + on the payee. In both cases the payer's row keeps the amount that was paid automatically, and the + next request waits in the bell as an ordinary Payment Request. + + + Launch the E2E Bitkit app on both instances with an Active allowance that has paid at least one request automatically, per auto-pay-under-limit.xml + On the payer instance, open the drawer menu, tap Subscriptions, tap the Allowances tab and tap the payee's row + Verify the detail sheet (id "AllowanceDetail") ends with a swipe control reading "Swipe To End Allowance" + Swipe the control to the end + Verify the sheet dismisses and the row's status line (id "AllowanceRowStatus") reads "Ended · " followed by the amount paid automatically, such as "Ended · $2.00 paid automatically", with the row dimmed + Tap the row and verify the detail explanation (id "AllowanceDetailStatus") reads "This allowance has ended. Every request asks again." with no swipe control + On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterEnd" + On the payer instance, verify it is not paid: no "Payment Executed" notification, and the request waits in the bell (id "PaymentRequestsBell") as an ordinary Payment Request + Dismiss that request + On the payee instance, open Subscriptions and the Allowances tab, and verify the payer's row reads "Ended" + Set and accept a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml + On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end + Verify the payee's row reads "Ended" + On the payer instance, verify its row for that allowance reads "Ended · $0.00 paid automatically" + On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterPayeeEnd" + On the payer instance, verify it is not paid and waits in the bell as an ordinary Payment Request, then dismiss it + + diff --git a/journeys/allowances/monthly-cap-reached.xml b/journeys/allowances/monthly-cap-reached.xml new file mode 100644 index 000000000..1b32ebdd3 --- /dev/null +++ b/journeys/allowances/monthly-cap-reached.xml @@ -0,0 +1,22 @@ + + + Requests keep paying themselves until the month's allowance is used up; the first request that + would exceed it is left to the payer with a "Limit Reached" notification and the ordinary Payment + Request sheet. The journey uses a $5 a payment, $10 a month allowance so two $4 requests fit and + the third does not. The second request can take a few extra seconds: after a payment the payee + publishes a new private payment list, and the payer waits for it rather than asking. + + + Launch the E2E Bitkit app on both instances with no Active allowance between them and the payer's notification permission granted + On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (id "AllowancePerPaymentStop-1") and the $10 stop (id "AllowanceMonthlyStop-0"), and have the payee accept it + Verify the payer's row reads "Active · $5 a payment" with "$ 10.00" over "Monthly limit" + On the payee instance, send the payer a one-time Payment Request for $4 with the note "Cap1" + On the payer instance, verify it is paid without a sheet and a "Payment Executed" notification reads "Auto-pay sent $4.00 to <payee>" + On the payee instance, wait for the payment to arrive, then send a second $4 request with the note "Cap2" + On the payer instance, verify it is paid without a sheet within about fifteen seconds, and a second "Payment Executed" notification is posted + On the payee instance, send a third $4 request with the note "Cap3" + On the payer instance, verify a "Limit Reached" notification reads "A $4.00 request from <payee> is above your allowance. Review it to pay." and the Payment Request sheet opens for $4.00 + Dismiss the request (id "PaymentRequestDismiss-<paymentRequestId>") + Open Subscriptions, tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (id "AllowancePaidSoFar") reads "$8.00" + + diff --git a/journeys/allowances/restart-never-pays-twice.xml b/journeys/allowances/restart-never-pays-twice.xml new file mode 100644 index 000000000..e991ca742 --- /dev/null +++ b/journeys/allowances/restart-never-pays-twice.xml @@ -0,0 +1,21 @@ + + + Killing the payer while an automatic payment is in flight must never lead to a second payment + after relaunch. The app records the request as taken before it hands the payment to the node, so + on relaunch it either sees the node finish the first attempt or hands the request back to the + payer as an ordinary Payment Request; it never starts a new automatic attempt. The kill has to + land within about two seconds of the request arriving, which the app log marks with "Handed an + allowance payment to the node". + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active, per set-and-accept.xml + On the payer instance, start tailing the simulator log (`xcrun simctl spawn <udid> log stream --predicate 'subsystem == "to.bitkit"'`) for "Handed an allowance payment to the node" so the kill can follow it at once + On the payee instance, send the payer a one-time Payment Request for $2 with the note "Devlog3" + As soon as the log line appears on the payer, run `xcrun simctl terminate <udid> to.bitkit` + Relaunch the payer with `xcrun simctl launch <udid> to.bitkit` and wait for the node to come back up + Verify no "Payment Executed" notification is posted for a second attempt after the relaunch + Open the drawer menu, tap Subscriptions and tap the Payments tab; verify "Devlog3" is listed exactly once + On the payee instance, verify at most one $2.00 payment arrives for "Devlog3" + If the kill landed before the node took the payment: on the payer, verify "Devlog3" comes back as an ordinary Payment Request in the bell (id "PaymentRequestsBell") rather than being paid automatically, and dismiss it + + diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml new file mode 100644 index 000000000..83f2bd35d --- /dev/null +++ b/journeys/allowances/set-and-accept.xml @@ -0,0 +1,26 @@ + + + The payer sets an allowance for a contact from the Allowances tab, and the payee sees the offer + open by itself and accepts it. Until the payee answers, the payer's row reads "Waiting for an + answer"; after it, both sides show the allowance as Active. The other allowance journeys start + from the Active state this one ends in. + + + Launch the E2E Bitkit app with Paykit UI enabled on both instances, each with the other saved as a linked contact, the payer holding a spendable balance above 50,000 sats with a usable Lightning channel + On the payer instance, open the drawer menu and tap Subscriptions + Tap the Allowances tab (id "Tab-allowances") + Verify the empty state (id "AllowancesEmpty") reads "Set up allowances" over the group illustration, with the footer button "Add Allowance" + Tap Add Allowance (id "AllowanceAdd") + Verify the Choose Contact sheet lists the payee and tap it (id "AllowanceContact-<displayName>") + Verify the Set Allowance sheet (id "SetAllowance") shows the payee's card, a PAYMENT LIMIT slider (id "AllowancePerPayment") and a MONTHLY ALLOWANCE slider (id "AllowanceMonthly") + Tap the $5 stop of the payment limit slider (id "AllowancePerPaymentStop-1") and the $50 stop of the monthly slider (id "AllowanceMonthlyStop-1") + Verify the summary (id "AllowanceSummary") reads "Requests up to $5 will be paid automatically, up to $50 a month, without asking you each time." + Tap Save Allowance (id "AllowanceSave") + Verify the sheet dismisses and the list shows one row for the payee (id "AllowanceRow-<allowanceId>") whose status line (id "AllowanceRowStatus") reads "Waiting for an answer", with "$ 50.00" over "Monthly limit" on the right + Switch to the payee instance and bring Bitkit to the foreground + Verify the Allowance review sheet (id "AllowanceReview") opens on its own within a few seconds, headed "<payer> offers an allowance", with the payer's contact card (id "AllowanceCounterparty"), PER PAYMENT "Up to $5.00" (id "AllowancePerPaymentValue") and EACH MONTH "Up to $50.00" (id "AllowanceMonthlyValue") + Tap Accept (id "AllowanceAccept") + Verify the sheet dismisses; open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit + Switch back to the payer instance and verify its row now reads "Active · $5 a payment" + +