From 2de9facbb384ecf38e8287d9afe6c4381bffa759 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 11:36:42 +0200 Subject: [PATCH 01/17] feat: add paykit allowance admission and auto-pay executor --- Bitkit/Services/PaykitAllowance.swift | 279 +++++++ Bitkit/Services/PaykitAllowanceExecutor.swift | 727 ++++++++++++++++++ Bitkit/Services/PaykitAllowanceManager.swift | 300 ++++++++ .../Services/PaykitPaymentProofService.swift | 12 +- .../PrivatePaykitService+Payments.swift | 52 ++ Bitkit/Services/PubkyService.swift | 127 +++ Bitkit/Services/PublicPaykitService.swift | 7 + Bitkit/Utilities/Keychain.swift | 2 + 8 files changed, 1503 insertions(+), 3 deletions(-) create mode 100644 Bitkit/Services/PaykitAllowance.swift create mode 100644 Bitkit/Services/PaykitAllowanceExecutor.swift create mode 100644 Bitkit/Services/PaykitAllowanceManager.swift diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift new file mode 100644 index 000000000..fb1571e65 --- /dev/null +++ b/Bitkit/Services/PaykitAllowance.swift @@ -0,0 +1,279 @@ +import Foundation +import Paykit + +/// An Allowance between this wallet and one contact link, built from the SDK record. +/// Eligibility runs on the trusted time passed in by the caller, never on a value read from the allowance itself. +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 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")! + var start = anchor + if date < anchor { + while start > date, let previous = calendar.date(byAdding: .month, value: -1, to: start) { + start = previous + } + } + var index = 0 + while let next = calendar.date(byAdding: .month, value: index + 1, to: anchor), next <= date { + index += 1 + start = next + } + let end = calendar.date(byAdding: .month, value: 1, to: start) ?? start + return (start, end) + } +} + +/// 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..4185f6801 --- /dev/null +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -0,0 +1,727 @@ +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) + 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 +} + +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() + + 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 + } + + // 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 { + 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 { + notifyLimitReached(request, identity: identity) + return .manual + } + + guard let payment = try await payer.resolve(request, eligibleIdentifiers: candidate.eligiblePaymentEndpointIdentifiers) 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)) + 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, identity: String?) async { + guard let identity 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)) + } + } 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, identity: String) async throws -> String? { + guard 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) + 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, identity: String) { + 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, identity: String) async { + 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..81d99155d --- /dev/null +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -0,0 +1,300 @@ +import Foundation +import Observation +import Paykit + +/// 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 + + var errorDescription: String? { + switch self { + case .contactNotLinked: t("subscriptions__allowance_error_not_linked") + case .unavailable: 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 var identity: String? + @ObservationIgnored private var isProcessingRequests = false + + init( + sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared, + executor: PaykitAllowanceExecutor = .shared, + now: @escaping () -> Date = { Date() } + ) { + self.sdk = sdk + self.executor = executor + self.now = now + } + + 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 + if identityChanged { + await executor.recover(identity: identity) + } + await refresh() + } + + func deactivate() { + identity = nil + allowances = [] + localState = PaykitAllowanceLocalState() + autoPaidRequestIds = [] + autoPaidSatsByAllowanceId = [:] + } + + 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. + func coversRequest(_ request: PaykitPaymentRequest) -> Bool { + allowances.contains { + $0.isAllower && $0.status(at: now()) == .active && + PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && + $0.counterpartyReceiverPath == request.counterpartyReceiverPath + } + } + + // 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 covered = requests.filter { $0.requiresAcceptance && coversRequest($0) } + guard !covered.isEmpty else { return false } + + isProcessingRequests = true + defer { isProcessingRequests = false } + var handledAny = false + for request in covered { + let result = await executor.autoPay(request, allowances: allowances, identity: identity) + if result == .started || result == .completed { + handledAny = true + } + } + if handledAny { + await refresh() + } + return handledAny + } + + func isAutomaticallyHandling(_ request: PaykitPaymentRequest) async -> Bool { + await executor.isHandling(request.id) + } + + 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 + } + } +} diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 7ec70dec6..33d6fb1ec 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -27,6 +27,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? @@ -46,6 +48,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind, billingPeriod: PaykitBillingPeriod? = nil, + allowanceId: String? = nil, paymentStarted: Bool = false, paymentIdentifier: String?, proofData: String?, @@ -60,6 +63,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 @@ -235,14 +239,16 @@ actor PaykitPaymentProofService { request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, - kind: PaykitPaymentProofKind + kind: PaykitPaymentProofKind, + allowanceId: String? = nil ) async throws { - let proof = try await pendingProof( + var proof = try await pendingProof( request: request, paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind ) + proof.allowanceId = allowanceId var pendingProofs = try await loadProofs() guard !pendingProofs.contains(where: { @@ -704,7 +710,7 @@ actor PaykitPaymentProofService { billingPeriod: pendingProof.billingPeriod?.sdkValue, paymentAppId: pendingProof.paymentAppId, paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier, - allowanceId: nil, + allowanceId: pendingProof.allowanceId, conversionQuoteId: nil, proof: Paykit.PrivateJsonObject(text: proofText) ) diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index c57a74258..1fdcc8695 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -1,3 +1,4 @@ +import BitkitCore import Foundation import Paykit @@ -431,6 +432,57 @@ 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 + ) + 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 { var endpoint = PublicPaykitService.parseEndpoint(identifier: $0.identifier, payload: $0.target.payload) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 99b0ed2cd..fb02846d3 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -885,6 +885,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 withSdk { sdk in try await sdk.linkedPeers() diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 791b68707..613f070ca 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -36,6 +36,13 @@ struct PrivatePaykitPaymentContext: Equatable { } } +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 054abe1d6..b80c738b4 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -14,6 +14,7 @@ enum KeychainEntryType { case paykitKeyGeneration(publicKey: String) case paykitRecoveryBackup case paykitPendingBackupRestore + case paykitAllowanceState case pubkySecretKey var storageKey: String { @@ -30,6 +31,7 @@ enum KeychainEntryType { case let .paykitKeyGeneration(publicKey): "paykit_key_generation_\(publicKey)" case .paykitRecoveryBackup: "paykit_recovery_backup" case .paykitPendingBackupRestore: "paykit_pending_backup_restore" + case .paykitAllowanceState: "paykit_allowance_state" case .pubkySecretKey: "pubky_secret_key" } } From 621fcda0a06b2c27ffcd2a0822a37bebc8a06597 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 11:42:35 +0200 Subject: [PATCH 02/17] feat: add allowances tab, set allowance sheet and auto-pay wiring --- Bitkit/AppScene.swift | 50 ++ .../Localization/en.lproj/Localizable.strings | 47 ++ Bitkit/Services/PaykitAllowanceExecutor.swift | 19 +- Bitkit/Services/PaykitAllowanceManager.swift | 28 + Bitkit/ViewModels/AppViewModel.swift | 6 +- .../PaymentRequests/PaymentRequestsView.swift | 5 +- .../Views/Subscriptions/AllowancesView.swift | 716 ++++++++++++++++++ .../Subscriptions/SubscriptionsView.swift | 26 + .../Wallets/Send/SendConfirmationView.swift | 27 + 9 files changed, 916 insertions(+), 8 deletions(-) create mode 100644 Bitkit/Views/Subscriptions/AllowancesView.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ec723d004..09399af0f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -237,6 +237,7 @@ struct AppScene: View { @State private var receivedPaymentBackfillCache = PaykitReceivedPaymentBackfillCache( activityChanges: CoreService.shared.activity.activitiesChangedPublisher ) + @State private var paykitAllowanceManager = PaykitAllowanceManager() @State private var initialPaykitSyncGeneration = 0 @State private var hideSplash = false @@ -429,6 +430,7 @@ struct AppScene: View { .environment(hwWalletManager) .environment(calculatorInputManager) .environment(paykitPaymentRequestManager) + .environment(paykitAllowanceManager) } private var appEventContent: some View { @@ -438,6 +440,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 refreshPrivateOnlyPaykitApp() await refreshIncomingPaykitPaymentRequests(presentItems: false) @@ -452,6 +455,7 @@ struct AppScene: View { } else if authState == .idle { contactsManager.reset() paykitPaymentRequestManager.clear() + paykitAllowanceManager.deactivate() } } .onReceive(contactsManager.$contacts) { contacts in @@ -477,6 +481,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) } } @@ -1125,6 +1132,10 @@ struct AppScene: View { } guard let identity = pubkyProfile.publicKey else { return } await paykitPaymentRequestManager.refresh() + await paykitAllowanceManager.refresh() + if await paykitAllowanceManager.processIncomingRequests(paykitPaymentRequestManager.pendingRequests) { + await paykitPaymentRequestManager.refresh() + } guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return } @@ -1236,6 +1247,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), @@ -1459,6 +1471,40 @@ struct AppScene: View { await presentNextIncomingPaykitPaymentRequest() } + private func handlePaykitAllowanceEvent(_ event: PaykitAllowanceEvent) { + switch event { + case let .paidAutomatically(counterparty, amountSats): + 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() + try? await activity.syncLdkNodePayments() + } + 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 { @@ -1472,6 +1518,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 62d9d200e..e14e674c6 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1755,3 +1755,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/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 4185f6801..3f51dc11c 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -224,6 +224,7 @@ actor PaykitAllowanceExecutor { 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, @@ -239,6 +240,10 @@ actor PaykitAllowanceExecutor { self.now = now } + func activate(identity: String?) { + activeIdentity = identity + } + // MARK: Local state func localState(identity: String) -> PaykitAllowanceLocalState { @@ -586,8 +591,8 @@ actor PaykitAllowanceExecutor { } /// Called from the node's payment events for every outbound Lightning payment; only journaled ones are Allowance work. - func lightningPaymentSettled(paymentHash: String, succeeded: Bool, identity: String?) async { - guard let identity else { return } + 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) @@ -608,8 +613,8 @@ actor PaykitAllowanceExecutor { /// 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, identity: String) async throws -> String? { - guard request.billingPeriod == nil, request.direction == .incoming else { return nil } + 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( @@ -663,7 +668,8 @@ actor PaykitAllowanceExecutor { } } - func manualLightningPaymentSent(attemptId: String, paymentHash: String, identity: String) { + 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() @@ -671,7 +677,8 @@ actor PaykitAllowanceExecutor { } } - func finishManualPayment(attemptId: String, outcome: Paykit.PaymentOutcome, transactionId: String? = nil, identity: String) async { + 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 } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 81d99155d..eecd45867 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -1,6 +1,7 @@ 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. @@ -87,6 +88,7 @@ final class PaykitAllowanceManager { } let identityChanged = self.identity != identity self.identity = identity + await executor.activate(identity: identity) if identityChanged { await executor.recover(identity: identity) } @@ -95,6 +97,7 @@ final class PaykitAllowanceManager { func deactivate() { identity = nil + Task { await executor.activate(identity: nil) } allowances = [] localState = PaykitAllowanceLocalState() autoPaidRequestIds = [] @@ -298,3 +301,28 @@ final class PaykitAllowanceManager { } } } + +/// 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/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 08625a8f0..45742786f 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1429,6 +1429,7 @@ extension AppViewModel { paymentHash: paymentHash, preimage: paymentPreimage ) + await PaykitAllowanceExecutor.shared.lightningPaymentSettled(paymentHash: paymentHash, succeeded: true) } let outcome = QuickPayPaymentCoordinator.shared.complete( paymentId: paymentId, @@ -1462,7 +1463,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 fe27739ba..e119aed97 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -354,6 +354,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 { @@ -478,7 +479,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..a9b3f4542 --- /dev/null +++ b/Bitkit/Views/Subscriptions/AllowancesView.swift @@ -0,0 +1,716 @@ +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.fiatValue(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) + } + + @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": fiat(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.fiat(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"), variant: .secondary, 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) + + HStack(spacing: 0) { + ForEach(stops.indices, id: \.self) { index in + Button { + select(index) + } label: { + CaptionMText(AllowanceAmountText.short(stops[index]), textColor: .textPrimary) + .frame(maxWidth: .infinity, alignment: alignment(for: index)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("\(identifier)Stop-\(index)") + } + } + } + .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 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 { + 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 9668e750a..f1b9beb4b 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"), variant: .secondary) { + 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 c19e9337f..f4a94b497 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -852,6 +852,7 @@ struct SendConfirmationView: View { var onchainPaymentStarted = false var lightningPaymentSubmitted = false var privatePaymentListOutcome = PrivatePaymentListSendOutcome.definitePreBroadcastFailure + var manualAllowanceAttemptId: String? do { try validateIncomingPaymentRequestContext(contactPaymentContext) @@ -881,6 +882,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 @@ -894,6 +902,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) @@ -991,6 +1002,13 @@ struct SendConfirmationView: View { shouldCancelPaymentProof = false privatePaymentListOutcome = .succeeded await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome) + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.finishManualPayment( + attemptId: manualAllowanceAttemptId, + outcome: .succeeded, + transactionId: txid + ) + } if let incomingPaymentRequest, let preparedPaymentProof { guard let paymentAppId = try contactPaymentContext?.privatePaymentContext?.paymentAppId( for: preparedPaymentProof.endpointIdentifier @@ -1028,6 +1046,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) } @@ -1059,6 +1080,12 @@ struct SendConfirmationView: View { } } await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome) + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.finishManualPayment( + attemptId: manualAllowanceAttemptId, + outcome: lightningPaymentSubmitted || onchainPaymentStarted ? .unknown : .failed + ) + } if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } From 6eafd42a68bd698f082ee9024f4d6530bc748374 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 11:51:44 +0200 Subject: [PATCH 03/17] fix: attribute auto-paid activity and round allowance estimates --- Bitkit/AppScene.swift | 8 ++- Bitkit/Services/PaykitAllowanceExecutor.swift | 8 +-- .../Views/Subscriptions/AllowancesView.swift | 54 ++++++++++++++----- .../Subscriptions/SubscriptionsView.swift | 2 +- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 09399af0f..68a74b42d 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1473,7 +1473,7 @@ struct AppScene: View { private func handlePaykitAllowanceEvent(_ event: PaykitAllowanceEvent) { switch event { - case let .paidAutomatically(counterparty, amountSats): + case let .paidAutomatically(counterparty, amountSats, paymentId): let title = t("subscriptions__allowance_executed_title") let description = t( "subscriptions__allowance_executed_description", @@ -1484,7 +1484,11 @@ struct AppScene: View { }) Task { await paykitAllowanceManager.refresh() - try? await activity.syncLdkNodePayments() + 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") diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 3f51dc11c..1cfe5e4f1 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -183,7 +183,7 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { } enum PaykitAllowanceEvent: Equatable { - case paidAutomatically(counterparty: String, amountSats: UInt64) + case paidAutomatically(counterparty: String, amountSats: UInt64, paymentId: String) case limitReached(counterparty: String, amountSats: UInt64) case ledgerChanged } @@ -586,7 +586,7 @@ actor PaykitAllowanceExecutor { } 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)) + Self.eventSubject.send(.paidAutomatically(counterparty: request.counterparty, amountSats: request.amountSats, paymentId: txid)) return .completed } @@ -601,7 +601,9 @@ actor PaykitAllowanceExecutor { 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)) + 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") diff --git a/Bitkit/Views/Subscriptions/AllowancesView.swift b/Bitkit/Views/Subscriptions/AllowancesView.swift index a9b3f4542..6850d3882 100644 --- a/Bitkit/Views/Subscriptions/AllowancesView.swift +++ b/Bitkit/Views/Subscriptions/AllowancesView.swift @@ -193,7 +193,7 @@ struct AllowanceMoney: View { return AllowanceAmountText.formatted(usd) } guard let sats else { return "—" } - return AllowanceAmountText.fiatValue(sats: sats, currency: currency) + return AllowanceAmountText.limitValue(sats: sats, currency: currency) } @ViewBuilder @@ -234,13 +234,25 @@ enum AllowanceAmountText { "$" + 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": fiat(sats: sats, currency: currency)]) + return t("subscriptions__allowance_per_payment_short", variables: ["amount": "$" + limitValue(sats: sats, currency: currency)]) } } @@ -269,7 +281,7 @@ private struct AllowanceLimitsGrid: View { 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.fiat(sats: $0, currency: currency) } ?? "—"])) + 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) @@ -423,7 +435,7 @@ struct SetAllowanceView: View { .padding(.bottom, 16) } - CustomButton(title: t("subscriptions__allowance_save"), variant: .secondary, isLoading: allowances.isWorking) { + CustomButton(title: t("subscriptions__allowance_save"), isLoading: allowances.isWorking) { await save() } .accessibilityIdentifier("AllowanceSave") @@ -499,18 +511,23 @@ struct AllowanceStepSlider: View { } .frame(height: knobSize) - HStack(spacing: 0) { - ForEach(stops.indices, id: \.self) { index in - Button { - select(index) - } label: { - CaptionMText(AllowanceAmountText.short(stops[index]), textColor: .textPrimary) - .frame(maxWidth: .infinity, alignment: alignment(for: index)) + 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)") } - .buttonStyle(.plain) - .accessibilityIdentifier("\(identifier)Stop-\(index)") } } + .frame(height: 18) } .accessibilityElement(children: .contain) .accessibilityIdentifier(identifier) @@ -541,6 +558,14 @@ struct AllowanceStepSlider: View { 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 } @@ -610,6 +635,9 @@ struct AllowanceReviewView: View { .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) } } diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index f1b9beb4b..a606d9547 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -158,7 +158,7 @@ struct SubscriptionsView: View { } .accessibilityIdentifier("SubscriptionCreate") } else if selectedTab == .allowances { - CustomButton(title: t("subscriptions__allowance_add"), variant: .secondary) { + CustomButton(title: t("subscriptions__allowance_add")) { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .allowanceContact)) } .accessibilityIdentifier("AllowanceAdd") From f1291e11f397927b54f88e0d1f8741a788beb9ed Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 12:03:25 +0200 Subject: [PATCH 04/17] fix: keep pre-allowance requests manual and log allowance decisions --- Bitkit/Services/PaykitAllowance.swift | 13 +++++++ Bitkit/Services/PaykitAllowanceExecutor.swift | 2 + Bitkit/Services/PaykitAllowanceManager.swift | 39 +++++++++++++++---- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index fb1571e65..7a2937003 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -139,6 +139,19 @@ struct PaykitAllowance: Identifiable, Hashable { } } +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 { diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 1cfe5e4f1..a793e2432 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -446,6 +446,7 @@ actor PaykitAllowanceExecutor { 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 } @@ -661,6 +662,7 @@ actor PaykitAllowanceExecutor { 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 diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index eecd45867..bf67ffa07 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -51,6 +51,7 @@ final class PaykitAllowanceManager { @ObservationIgnored private let now: () -> Date @ObservationIgnored private var identity: String? @ObservationIgnored private var isProcessingRequests = false + @ObservationIgnored private var manualRequestIds: [PaykitPaymentRequest.ID: Int] = [:] init( sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared, @@ -129,15 +130,29 @@ final class PaykitAllowanceManager { entry.allowances.reduce(0) { $0 + (autoPaidSatsByAllowanceId[$1.allowanceId] ?? 0) } } - /// Whether an incoming request is covered by an active Allowance this wallet granted. + /// 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 { - $0.isAllower && $0.status(at: now()) == .active && - PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && - $0.counterpartyReceiverPath == request.counterpartyReceiverPath + 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 { @@ -265,7 +280,10 @@ final class PaykitAllowanceManager { /// caller refreshes before presenting the rest for manual payment. func processIncomingRequests(_ requests: [PaykitPaymentRequest]) async -> Bool { guard let identity, !isProcessingRequests else { return false } - let covered = requests.filter { $0.requiresAcceptance && coversRequest($0) } + let signature = allowancesSignature + let covered = requests.filter { + $0.requiresAcceptance && coversRequest($0) && manualRequestIds[$0.id] != signature + } guard !covered.isEmpty else { return false } isProcessingRequests = true @@ -273,8 +291,13 @@ final class PaykitAllowanceManager { var handledAny = false for request in covered { let result = await executor.autoPay(request, allowances: allowances, identity: identity) - if result == .started || result == .completed { + switch result { + case .started, .completed: handledAny = true + case .manual: + manualRequestIds[request.id] = signature + case .notCovered: + break } } if handledAny { @@ -287,6 +310,8 @@ final class PaykitAllowanceManager { await executor.isHandling(request.id) } + static let acceptanceClockTolerance: TimeInterval = 30 + static let allowedPaymentEndpointIdentifiers: [String] = PaykitIssuerInterop.supportedEndpointIdentifiers( PublicPaykitService.MethodId.publishableMethodIds.map(\.rawValue), network: Env.network From 8d04d1f12602ec2775f903cc074585f921e228a9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 12:10:06 +0200 Subject: [PATCH 05/17] test: cover allowance admission, limits and restart recovery --- .../PaykitAllowanceExecutorTests.swift | 856 ++++++++++++++++++ BitkitTests/PaykitAllowanceTests.swift | 551 +++++++++++ 2 files changed, 1407 insertions(+) create mode 100644 BitkitTests/PaykitAllowanceExecutorTests.swift create mode 100644 BitkitTests/PaykitAllowanceTests.swift diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift new file mode 100644 index 000000000..889c04f1e --- /dev/null +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -0,0 +1,856 @@ +@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 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: 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 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(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 resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? { + called("resolve") + 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..a1e4a8b3f --- /dev/null +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -0,0 +1,551 @@ +@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 + ) + + XCTExpectFailure( + "PaykitAllowanceTime.monthlyWindow steps one month from an already clamped date instead of counting from the original anchor" + ) { + 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: 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)) + } +} From 44ae954b5fbf7fe911edfba699cc380e56a907e9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 12:34:53 +0200 Subject: [PATCH 06/17] fix: count allowance months from the original anchor --- Bitkit/Services/PaykitAllowance.swift | 19 +++++++------- BitkitTests/PaykitAllowanceTests.swift | 34 ++++++++++++-------------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index 7a2937003..5570fbd7b 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -230,19 +230,18 @@ enum PaykitAllowanceTime { static func monthlyWindow(anchor: Date, containing date: Date) -> (start: Date, end: Date) { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "UTC")! - var start = anchor - if date < anchor { - while start > date, let previous = calendar.date(byAdding: .month, value: -1, to: start) { - start = previous - } + 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 } - var index = 0 - while let next = calendar.date(byAdding: .month, value: index + 1, to: anchor), next <= date { + while boundary(index + 1) <= date { index += 1 - start = next } - let end = calendar.date(byAdding: .month, value: 1, to: start) ?? start - return (start, end) + return (boundary(index), boundary(index + 1)) } } diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index a1e4a8b3f..7c7283c5d 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -174,26 +174,22 @@ final class PaykitAllowanceTests: XCTestCase { isLive: true ) - XCTExpectFailure( - "PaykitAllowanceTime.monthlyWindow steps one month from an already clamped date instead of counting from the original anchor" - ) { - 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" - ) + 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 From 460e7ad13b3d5112b27a83d8f65dc4e5d81d5a0d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 12:34:53 +0200 Subject: [PATCH 07/17] test: pass the proof allowance id in existing paykit fixtures --- BitkitTests/PaykitPaymentProofServiceTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 36a733969..b6672799d 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1502,7 +1502,7 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { billingPeriod: proof.billingPeriod, paymentAppId: proof.paymentAppId, paymentEndpointIdentifier: proof.paymentEndpointIdentifier, - allowanceId: nil, + allowanceId: proof.allowanceId, conversionQuoteId: nil, proof: proof.proof, recordedAt: "2027-01-15T08:01:00Z" From c725de9dfc8ad52fb7ef325414d6bb9cafdc7718 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 12:34:53 +0200 Subject: [PATCH 08/17] fix: leave wallets without an allowance ledger untouched at launch --- Bitkit/Services/PaykitAllowanceExecutor.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index a793e2432..6272bf682 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -311,6 +311,8 @@ actor PaykitAllowanceExecutor { /// 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 { From d577b84609f94ed23ad91b3f2b22f852894a462b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 20:37:53 +0200 Subject: [PATCH 09/17] fix: hold automatic payments until the payee's next list and usable channels --- Bitkit/Services/PaykitAllowanceExecutor.swift | 11 ++- Bitkit/Services/PaykitAllowanceManager.swift | 35 ++++++++-- .../PrivatePaykitService+Payments.swift | 5 ++ .../PaykitAllowanceExecutorTests.swift | 68 +++++++++++++++++++ 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 6272bf682..8a302931e 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -196,6 +196,8 @@ enum PaykitAllowanceAutoPayResult: Equatable { /// 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 { @@ -453,7 +455,14 @@ actor PaykitAllowanceExecutor { return .manual } - guard let payment = try await payer.resolve(request, eligibleIdentifiers: candidate.eligiblePaymentEndpointIdentifiers) else { + 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 } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index bf67ffa07..7cbaf9e7a 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -28,11 +28,12 @@ struct PaykitAllowanceEntry: Identifiable, Hashable { enum PaykitAllowanceError: LocalizedError { case contactNotLinked case unavailable + case paymentListPending var errorDescription: String? { switch self { case .contactNotLinked: t("subscriptions__allowance_error_not_linked") - case .unavailable: t("subscriptions__allowance_error_unavailable") + case .unavailable, .paymentListPending: t("subscriptions__allowance_error_unavailable") } } } @@ -49,18 +50,31 @@ final class PaykitAllowanceManager { @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() } + 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] { @@ -91,6 +105,8 @@ final class PaykitAllowanceManager { self.identity = identity await executor.activate(identity: identity) if identityChanged { + manualRequestIds = [:] + waitingRequestIds = [] await executor.recover(identity: identity) } await refresh() @@ -103,6 +119,8 @@ final class PaykitAllowanceManager { localState = PaykitAllowanceLocalState() autoPaidRequestIds = [] autoPaidSatsByAllowanceId = [:] + manualRequestIds = [:] + waitingRequestIds = [] } func refresh() async { @@ -285,6 +303,10 @@ final class PaykitAllowanceManager { $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 } @@ -294,10 +316,14 @@ final class PaykitAllowanceManager { 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: - break + waitingRequestIds.remove(request.id) } } if handledAny { @@ -307,7 +333,8 @@ final class PaykitAllowanceManager { } func isAutomaticallyHandling(_ request: PaykitPaymentRequest) async -> Bool { - await executor.isHandling(request.id) + if waitingRequestIds.contains(request.id), coversRequest(request) { return true } + return await executor.isHandling(request.id) } static let acceptanceClockTolerance: TimeInterval = 30 diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 1fdcc8695..5da7bbf85 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -451,6 +451,11 @@ extension PrivatePaykitService { 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) diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 889c04f1e..084a5ab0c 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -84,6 +84,68 @@ final class PaykitAllowanceExecutorTests: XCTestCase { 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([ @@ -588,6 +650,7 @@ private actor AllowancePayerMock: PaykitAllowancePaying { 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] = [] @@ -603,8 +666,13 @@ private actor AllowancePayerMock: PaykitAllowancePaying { 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 } From 80dde8ea746306140364e40161aaadaa5dcd344b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 25 Sep 2026 00:23:56 +0200 Subject: [PATCH 10/17] docs: add the allowance journeys --- journeys/allowances/README.md | 54 +++++++++++++++++++ journeys/allowances/above-limit-asks.xml | 19 +++++++ journeys/allowances/auto-pay-under-limit.xml | 22 ++++++++ journeys/allowances/end-stops-auto-pay.xml | 26 +++++++++ journeys/allowances/monthly-cap-reached.xml | 22 ++++++++ .../allowances/restart-never-pays-twice.xml | 21 ++++++++ journeys/allowances/set-and-accept.xml | 26 +++++++++ 7 files changed, 190 insertions(+) create mode 100644 journeys/allowances/README.md create mode 100644 journeys/allowances/above-limit-asks.xml create mode 100644 journeys/allowances/auto-pay-under-limit.xml create mode 100644 journeys/allowances/end-stops-auto-pay.xml create mode 100644 journeys/allowances/monthly-cap-reached.xml create mode 100644 journeys/allowances/restart-never-pays-twice.xml create mode 100644 journeys/allowances/set-and-accept.xml 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" + + From b8069449f9d742794e503b01169d866c9c1b19fd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 25 Sep 2026 00:37:47 +0200 Subject: [PATCH 11/17] chore: add changelog fragment --- changelog.d/next/799.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/799.added.md 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. From dbe7fd464c0e011cc769880cf4075e2aa974eaf0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 19:15:55 +0200 Subject: [PATCH 12/17] fix: adapt allowances to the shared paykit identity An Allowance now binds two identities, so a grant is one SDK Allowance on the contact's single Encrypted Link and coverage no longer depends on a receiver folder. Automatic payments claim the request before the automatic Acceptance, pass the payee's payment app on the proof and settle the private payment list with the same outcomes the manual flow uses. --- Bitkit/Services/PaykitAllowance.swift | 10 +- Bitkit/Services/PaykitAllowanceExecutor.swift | 136 ++++++++-- Bitkit/Services/PaykitAllowanceManager.swift | 76 ++---- .../PrivatePaykitService+Payments.swift | 18 +- Bitkit/Services/PubkyService.swift | 22 +- .../PaykitAllowanceExecutorTests.swift | 245 +++++++++++++++--- BitkitTests/PaykitAllowanceTests.swift | 71 ++--- journeys/allowances/README.md | 8 +- 8 files changed, 400 insertions(+), 186 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index 5570fbd7b..a2e00bb1d 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -1,12 +1,11 @@ import Foundation import Paykit -/// An Allowance between this wallet and one contact link, built from the SDK record. +/// An Allowance between this wallet's identity and one contact's identity, built from the SDK record. /// Eligibility runs on the trusted time passed in by the caller, never on a value read from the allowance itself. struct PaykitAllowance: Identifiable, Hashable { struct ID: Codable, Hashable { let counterparty: String - let counterpartyReceiverPath: String let allowanceId: String } @@ -41,7 +40,6 @@ struct PaykitAllowance: Identifiable, Hashable { let lastEventAt: Date? var counterparty: String { id.counterparty } - var counterpartyReceiverPath: String { id.counterpartyReceiverPath } var allowanceId: String { id.allowanceId } init?(record: Paykit.AllowanceRecord) { @@ -52,11 +50,7 @@ struct PaykitAllowance: Identifiable, Hashable { else { return nil } let monthly = terms.periodLimits().first { Self.isMonthly($0.period()) } - id = ID( - counterparty: record.counterparty, - counterpartyReceiverPath: record.counterpartyReceiverPath, - allowanceId: record.allowanceId - ) + id = ID(counterparty: record.counterparty, allowanceId: record.allowanceId) self.role = role lifecycleState = record.state isProposedByMe = record.proposalOutboundMessageId != nil diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 8a302931e..549b82f5c 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -8,17 +8,16 @@ protocol PaykitAllowanceSdkHandling: Sendable { 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 + func acceptAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord + func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord + func endAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord @discardableResult - func receivePrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.PrivateStreamIntakeReport + func receivePrivateMessages(counterparty: String) async throws -> Paykit.PrivateStreamIntakeReport @discardableResult - func processOutboundPrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.OutboundPrivateSendReport + func processOutboundPrivateMessages(counterparty: 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] @@ -42,8 +41,8 @@ protocol PaykitAllowanceSdkHandling: Sendable { 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. +/// Local Allowance state kept per identity: the USD labels of each grant, 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 @@ -120,13 +119,16 @@ struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring { /// 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 consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws + func resolvePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID, outcome: PrivatePaymentListSendOutcome) async + func claimForExecution(_ request: PaykitPaymentRequest) async throws + func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws + func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, 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 completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, paymentEndpointIdentifier: String) async func failLightningPayment(paymentHash: String) async func cancelProofPreparation(_ request: PaykitPaymentRequest) async } @@ -136,16 +138,43 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { 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 consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws { + try await PrivatePaykitService.shared.consumePrivatePaymentList(publicKey: publicKey, context: context, attemptId: attemptId) } - func prepareProof(_ request: PaykitPaymentRequest, paymentEndpointIdentifier: String, allowanceId: String?) async throws { + func resolvePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + outcome: PrivatePaymentListSendOutcome + ) async { + do { + try await PrivatePaykitService.shared.resolvePrivatePaymentListConsumption( + publicKey: publicKey, + context: context, + attemptId: attemptId, + outcome: outcome + ) + } catch { + Logger.error("Failed to resolve private Paykit payment list consumption: \(error)", context: "PaykitAllowance") + } + } + + func claimForExecution(_ request: PaykitPaymentRequest) async throws { + try await PaykitPaymentRequestService().claimForPayment(request) + } + + func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { + try await PaykitPaymentRequestService().ensurePaymentAllowed(request) + } + + func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, allowanceId: String?) async throws { guard let kind = PaykitPaymentProofKind(paymentEndpointIdentifier: paymentEndpointIdentifier) else { throw PaykitPaymentRequestError.requestUnavailable } try await PaykitPaymentProofService.shared.prepare( request: request, + paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind, allowanceId: allowanceId @@ -169,8 +198,13 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { 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 completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, paymentEndpointIdentifier: String) async { + await PaykitPaymentProofService.shared.completeOnchainPayment( + request, + txid: txid, + paymentAppId: paymentAppId, + paymentEndpointIdentifier: paymentEndpointIdentifier + ) } func failLightningPayment(paymentHash: String) async { @@ -313,7 +347,7 @@ actor PaykitAllowanceExecutor { /// 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. + // No ledger means nothing was ever admitted, so there is nothing to recover and no reason to create one at launch. guard try await sdk.allowanceAccountingState() != nil else { return } let state = try await ensureReconciled(identity: identity) for attempt in state.history.occurrences.flatMap(\.attempts) { @@ -411,8 +445,7 @@ actor PaykitAllowanceExecutor { !inFlightRequestIds.contains(request.id), allowances.contains(where: { $0.isAllower && $0.lifecycleState == .accepted && - PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && - $0.counterpartyReceiverPath == request.counterpartyReceiverPath + PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) }) else { return .notCovered } @@ -435,7 +468,6 @@ actor PaykitAllowanceExecutor { ) async throws -> PaykitAllowanceAutoPayResult { let scope = Paykit.PaymentRequestScope( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId ) let selectionTime = trustedTime(identity: identity) @@ -468,12 +500,15 @@ actor PaykitAllowanceExecutor { } let endpointIdentifier = payment.endpoint.methodId.rawValue + let paymentAppId = try payment.context.paymentAppId(for: endpointIdentifier) + // The SDK requires this app to hold the shared execution claim before it queues an automatic Acceptance. + try await payer.claimForExecution(request) 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) + try? await sdk.processOutboundPrivateMessages(counterparty: request.counterparty) let occurrence = Paykit.PaymentOccurrence(request: scope, billingPeriod: nil) let reservation = try await sdk.reserveAutomaticPayment( @@ -508,7 +543,7 @@ actor PaykitAllowanceExecutor { ) // 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) + try? await sdk.receivePrivateMessages(counterparty: request.counterparty) let handoff = try await sdk.beginPaymentExecution( attemptId: prepared.attemptId, checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity)) @@ -519,19 +554,50 @@ actor PaykitAllowanceExecutor { } setStage(.submitted, attemptId: submitted.attemptId, identity: identity) + let listAttemptId = UUID() do { - try await payer.consumePaymentList(publicKey: request.counterparty, context: payment.context) - try await payer.prepareProof(request, paymentEndpointIdentifier: endpointIdentifier, allowanceId: submitted.allowanceId) + try await payer.consumePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: listAttemptId) + try await payer.ensurePaymentAllowed(request) + try await payer.prepareProof( + request, + paymentAppId: paymentAppId, + paymentEndpointIdentifier: endpointIdentifier, + allowanceId: submitted.allowanceId + ) } catch { await payer.cancelProofPreparation(request) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) 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 payLightning( + request, + payment: payment, + paymentHash: paymentHash, + attemptId: submitted.attemptId, + listAttemptId: listAttemptId, + identity: identity + ) } - return try await payOnchain(request, payment: payment, attemptId: submitted.attemptId, identity: identity) + return try await payOnchain( + request, + payment: payment, + paymentAppId: paymentAppId, + attemptId: submitted.attemptId, + listAttemptId: listAttemptId, + identity: identity + ) + } + + private func releasePaymentList( + _ request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + attemptId: UUID, + outcome: PrivatePaymentListSendOutcome + ) async { + await payer.resolvePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: attemptId, outcome: outcome) } private func payLightning( @@ -539,12 +605,14 @@ actor PaykitAllowanceExecutor { payment: PrivatePaykitAllowancePayment, paymentHash: String, attemptId: String, + listAttemptId: UUID, identity: String ) async throws -> PaykitAllowanceAutoPayResult { do { try await payer.associateLightningPayment(request, paymentHash: paymentHash) } catch { await payer.cancelProofPreparation(request) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } @@ -555,9 +623,11 @@ actor PaykitAllowanceExecutor { } catch { // LDK rejected the payment before routing it. await payer.failLightningPayment(paymentHash: paymentHash) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) setStage(.sent, attemptId: attemptId, identity: identity) Logger.info("Handed an allowance payment to the node", context: "PaykitAllowance") return .started @@ -566,7 +636,9 @@ actor PaykitAllowanceExecutor { private func payOnchain( _ request: PaykitPaymentRequest, payment: PrivatePaykitAllowancePayment, + paymentAppId: String, attemptId: String, + listAttemptId: UUID, identity: String ) async throws -> PaykitAllowanceAutoPayResult { let address = payment.endpoint.value @@ -574,6 +646,7 @@ actor PaykitAllowanceExecutor { try await payer.markOnchainPaymentStarted(request, address: address) } catch { await payer.cancelProofPreparation(request) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } @@ -585,8 +658,10 @@ actor PaykitAllowanceExecutor { } catch { if PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error) { await payer.cancelProofPreparation(request) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) } else { + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) try await record(attemptId: attemptId, outcome: .unknown, identity: identity) } throw error @@ -596,7 +671,13 @@ actor PaykitAllowanceExecutor { 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) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .succeeded) + await payer.completeOnchainPayment( + request, + txid: txid, + paymentAppId: paymentAppId, + 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 @@ -633,7 +714,6 @@ actor PaykitAllowanceExecutor { 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) diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 7cbaf9e7a..8c371c9cf 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -3,16 +3,14 @@ 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. +/// One grant as the user sees it. An SDK Allowance binds this wallet's identity to the contact's identity and covers +/// every Paykit app they use, so a grant is one SDK Allowance. The row keeps the USD limits picked when it was made. 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 primary: PaykitAllowance { allowances[0] } var counterparty: String { primary.counterparty } var role: PaykitAllowance.Role { primary.role } @@ -127,7 +125,7 @@ final class PaykitAllowanceManager { guard let identity else { return } do { let records = try await sdk.listAllowances( - filter: Paykit.AllowanceFilter(counterparty: nil, counterpartyReceiverPath: nil, localRole: nil, states: []) + filter: Paykit.AllowanceFilter(counterparty: nil, localRole: nil, states: []) ) allowances = records .filter { $0.historyStatus == .consistent || $0.historyStatus == .unresolvedReferences } @@ -153,8 +151,7 @@ final class PaykitAllowanceManager { 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 + PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty) else { return false } guard let acceptedAt = allowance.lastEventAt, let createdAt = request.createdAt else { return true } return createdAt >= acceptedAt.addingTimeInterval(-Self.acceptanceClockTolerance) @@ -178,61 +175,39 @@ final class PaykitAllowanceManager { isWorking = true defer { isWorking = false } - let peers = try await sdk.linkedPeers().filter { + let isLinked = try await sdk.linkedPeers().contains { PubkyPublicKeyFormat.matches($0.counterparty, contact.publicKey) && $0.state == .linked } - let receiverPaths = Self.orderedReceiverPaths(peers.map(\.counterpartyReceiverPath)) - guard !receiverPaths.isEmpty else { throw PaykitAllowanceError.contactNotLinked } + guard isLinked 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 record = try await sdk.proposeAllowance(counterparty: contact.publicKey, localRole: .allower, terms: terms) + try? await sdk.processOutboundPrivateMessages(counterparty: contact.publicKey) let group = PaykitAllowanceLocalState.Group( id: UUID().uuidString.lowercased(), counterparty: contact.publicKey, limits: limits, - allowanceIds: allowanceIds, + allowanceIds: [record.allowanceId], createdAt: now() ) await executor.updateLocalState(identity: identity) { $0.groups.append(group) } - Logger.info("Proposed an allowance on \(allowanceIds.count) link(s)", context: "PaykitAllowance") + Logger.info("Proposed an allowance", 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 - ) + try await self.sdk.acceptAllowance(counterparty: allowance.counterparty, 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 - ) + try await self.sdk.rejectAllowance(counterparty: allowance.counterparty, allowanceId: allowance.allowanceId) } } @@ -241,16 +216,9 @@ final class PaykitAllowanceManager { 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 - ) + _ = try await sdk.endAllowance(counterparty: allowance.counterparty, allowanceId: allowance.allowanceId) endedAny = true - try? await sdk.processOutboundPrivateMessages( - counterparty: allowance.counterparty, - counterpartyReceiverPath: allowance.counterpartyReceiverPath - ) + try? await sdk.processOutboundPrivateMessages(counterparty: allowance.counterparty) } guard endedAny else { throw PaykitAllowanceError.unavailable } await refresh() @@ -263,10 +231,7 @@ final class PaykitAllowanceManager { for allowance in entry.allowances where allowance.isAnswerable { _ = try await response(allowance) respondedAny = true - try? await sdk.processOutboundPrivateMessages( - counterparty: allowance.counterparty, - counterpartyReceiverPath: allowance.counterpartyReceiverPath - ) + try? await sdk.processOutboundPrivateMessages(counterparty: allowance.counterparty) } guard respondedAny else { throw PaykitAllowanceError.unavailable } if let identity { @@ -343,15 +308,6 @@ final class PaykitAllowanceManager { 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. diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 5da7bbf85..5dc8d218c 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -443,17 +443,15 @@ extension PrivatePaykitService { let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty) else { return nil } - let consumedVersion = state.contacts[publicKey]? - .consumedPrivatePaymentListVersionsByReceiverPath[request.counterpartyReceiverPath] + let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersion 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) + schedulePrivatePaymentRecovery(for: publicKey) throw PaykitAllowanceError.paymentListPending } guard let paymentListVersion = prepared.resolution.privatePaymentListVersion else { return nil } @@ -461,26 +459,30 @@ extension PrivatePaykitService { 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) + ($0.methodId == .bitcoinLightningBolt11 || $0.methodId.onchainNetwork != nil) && $0.appId != 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 } + guard let endpoint = payable.first(where: { $0.methodId == methodId }), let appId = endpoint.appId else { continue } + let context = PrivatePaykitPaymentContext( + paymentAppsByEndpoint: [methodId.rawValue: appId], + paymentListVersion: paymentListVersion + ) 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), + context: context, lightningPaymentHash: invoice.paymentHash.hex, lightningInvoiceHasAmount: invoice.amountSatoshis != 0 ) } return PrivatePaykitAllowancePayment( endpoint: endpoint, - context: PrivatePaykitPaymentContext(receiverPath: request.counterpartyReceiverPath, paymentListVersion: paymentListVersion), + context: context, lightningPaymentHash: nil, lightningInvoiceHasAmount: false ) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index fb02846d3..0b9fd2c15 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -893,49 +893,47 @@ actor PaykitSdkService { 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 { + func acceptAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { try await withStateRevisionTracking { sdk in - try await sdk.acceptAllowance(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath, allowanceId: allowanceId) + try await sdk.acceptAllowance(counterparty: counterparty, allowanceId: allowanceId) } } - func rejectAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { try await withStateRevisionTracking { sdk in - try await sdk.rejectAllowance(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath, allowanceId: allowanceId) + try await sdk.rejectAllowance(counterparty: counterparty, allowanceId: allowanceId) } } - func endAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + func endAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { try await withStateRevisionTracking { sdk in - try await sdk.endAllowance(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath, allowanceId: allowanceId) + try await sdk.endAllowance(counterparty: counterparty, allowanceId: allowanceId) } } @discardableResult - func receivePrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.PrivateStreamIntakeReport { + func receivePrivateMessages(counterparty: String) async throws -> Paykit.PrivateStreamIntakeReport { try await withStateRevisionTracking { sdk in - try await sdk.receivePrivateMessages(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath) + try await sdk.receivePrivateMessages(counterparty: counterparty) } } @discardableResult - func processOutboundPrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.OutboundPrivateSendReport { + func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport { try await withStateRevisionTracking { sdk in - try await sdk.processOutboundPrivateMessages(counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath) + try await sdk.processOutboundPrivateMessages(counterparty: counterparty) } } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 084a5ab0c..f95593c47 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -9,14 +9,17 @@ final class PaykitAllowanceExecutorTests: XCTestCase { private static let admissionCalls = [ "evaluateAllowanceCandidates", + "claimForExecution", "acceptPaymentRequestAutomatically", "reserveAutomaticPayment", "receivePrivateMessages", "beginPaymentExecution", "consumePaymentList", + "ensurePaymentAllowed", "prepareProof", "associateLightningPayment", "payLightning", + "resolvePaymentList", ] // MARK: Admission @@ -29,7 +32,6 @@ final class PaykitAllowanceExecutorTests: XCTestCase { [Fixtures.allowance(role: .allowee)], [Fixtures.allowance(state: .proposed)], [Fixtures.allowance(state: .ended)], - [Fixtures.allowance(receiverPath: PaykitReceiverPath.server)], [Fixtures.allowance(counterparty: Fixtures.otherCounterpartyKey)], ] @@ -63,7 +65,14 @@ final class PaykitAllowanceExecutorTests: XCTestCase { let reservedRevisions = await harness.sdk.reservedAssociationRevisions XCTAssertEqual(reservedRevisions, [1]) let preparedProofs = await harness.payer.preparedProofs - XCTAssertEqual(preparedProofs, [.init(endpoint: Fixtures.lightningIdentifier, allowanceId: Fixtures.walletAllowanceId)]) + XCTAssertEqual( + preparedProofs, + [.init(appId: AllowanceHarness.paymentAppId, endpoint: Fixtures.lightningIdentifier, allowanceId: Fixtures.walletAllowanceId)] + ) + let claimed = await harness.payer.claimedRequestIds + XCTAssertEqual(claimed, [request.id]) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.uncertain], "A hand-off to the node keeps the payment list consumed") let associatedHashes = await harness.payer.associatedPaymentHashes XCTAssertEqual(associatedHashes, [AllowanceHarness.paymentHash]) let payments = await harness.payer.lightningPayments @@ -195,7 +204,6 @@ final class PaykitAllowanceExecutorTests: XCTestCase { Paykit.PaymentOccurrence( request: Paykit.PaymentRequestScope( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId ), billingPeriod: nil @@ -225,6 +233,34 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(log.contains("payLightning")) } + func testClaimFailureStaysManualBeforeAnyAcceptance() async throws { + let harness = AllowanceHarness() + await harness.payer.setClaimError(PaykitPaymentRequestError.requestUnavailable) + + 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("claimForExecution")) + XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically")) + XCTAssertFalse(log.contains("reserveAutomaticPayment")) + XCTAssertFalse(log.contains("payLightning")) + } + + func testNodeRejectionBeforeRoutingReleasesThePaymentListAndRecordsAFailure() async throws { + let harness = AllowanceHarness() + await harness.payer.setLightningError(AllowanceMockError.unsupported) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)]) + XCTAssertTrue(harness.log.entries.contains("failLightningPayment")) + } + // MARK: Settlement and recovery func testLightningSettlementRecordsSuccessForTheJournaledAttempt() async throws { @@ -412,20 +448,19 @@ final class PaykitAllowanceExecutorTests: XCTestCase { // MARK: Manager @MainActor - func testManagerGroupsOneGrantAcrossLinksWithTheWalletLinkAsPrimary() async throws { + func testManagerPresentsEachGrantAsOneEntryWithItsLimitsAndLeavesInvalidHistoryOut() 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: Fixtures.walletAllowanceId, terms: terms), + Fixtures.record(allowanceId: Fixtures.otherAllowanceId, 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], + allowanceIds: [Fixtures.walletAllowanceId], createdAt: Fixtures.now ) harness.store.seed(PaykitAllowanceLocalState(groups: [group]), identity: Fixtures.identityKey) @@ -434,21 +469,86 @@ final class PaykitAllowanceExecutorTests: XCTestCase { 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) + XCTAssertEqual(entries.map(\.id), ["group-1", Fixtures.otherAllowanceId]) + let grant = try XCTUnwrap(entries.first) + XCTAssertEqual(grant.allowances.map(\.allowanceId), [Fixtures.walletAllowanceId]) + XCTAssertEqual(grant.limits, Fixtures.limits) + XCTAssertEqual(grant.counterparty, Fixtures.counterpartyKey) + XCTAssertEqual(grant.role, .allower) + XCTAssertEqual(grant.perPaymentMaxSats, 5000) + XCTAssertEqual(grant.monthlyLimitSats, 50000) + XCTAssertEqual(grant.status(at: Fixtures.now), .active) XCTAssertNil(entries.last?.limits) XCTAssertEqual(manager.entry(id: "group-1")?.primary.allowanceId, Fixtures.walletAllowanceId) } + @MainActor + func testManagerCoversRequestsFromTheGrantedIdentityOnly() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: "2026-09-24T09:00:00Z")]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest()), "One Encrypted Link per identity: no folder is part of coverage") + XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey))) + } + + // MARK: Granting + + @MainActor + func testProposeWaitsForTheContactsLinkAndThenProposesOneAllowance() async throws { + let harness = AllowanceHarness() + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + let contact = Fixtures.contact() + + for state in [Paykit.LinkedPeerState.notLinked, .linking, .recoveryRequired, .blocked] { + await harness.sdk.setPeers([Fixtures.linkedPeer(state: state)]) + do { + try await manager.propose(to: contact, limits: Fixtures.limits) + XCTFail("Expected contactNotLinked while the link is \(state)") + } catch PaykitAllowanceError.contactNotLinked { + // expected + } + } + var proposals = await harness.sdk.proposals + XCTAssertEqual(proposals.count, 0, "A grant never reaches a contact that has no usable link") + XCTAssertTrue(manager.entries.isEmpty) + + await harness.sdk.setPeers([Fixtures.linkedPeer(state: .linked)]) + try await manager.propose(to: contact, limits: Fixtures.limits) + + proposals = await harness.sdk.proposals + XCTAssertEqual(proposals.count, 1, "One link per identity means one SDK Allowance") + XCTAssertEqual(proposals.first?.counterparty, Fixtures.counterpartyKey) + XCTAssertEqual(proposals.first?.localRole, .allower) + XCTAssertEqual(proposals.first?.terms.perPaymentAmount()?.maximum(), "0.00005") + XCTAssertEqual(proposals.first?.terms.allowedPaymentEndpointIdentifiers(), PaykitAllowanceManager.allowedPaymentEndpointIdentifiers) + XCTAssertTrue(harness.log.entries.contains("processOutboundPrivateMessages")) + let entry = try XCTUnwrap(manager.entries.first) + XCTAssertEqual(entry.allowances.map(\.allowanceId), ["proposed-1"]) + XCTAssertEqual(entry.limits, Fixtures.limits) + XCTAssertEqual(entry.status(at: Fixtures.now), .awaitingAnswer) + } + + @MainActor + func testProposeIgnoresALinkedPeerThatIsAnotherContact() async throws { + let harness = AllowanceHarness() + await harness.sdk.setPeers([Fixtures.linkedPeer(counterparty: Fixtures.otherCounterpartyKey, state: .linked)]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + do { + try await manager.propose(to: Fixtures.contact(), limits: Fixtures.limits) + XCTFail("Expected contactNotLinked") + } catch PaykitAllowanceError.contactNotLinked { + // expected + } + let proposals = await harness.sdk.proposals + XCTAssertEqual(proposals.count, 0) + } + @MainActor func testManagerLeavesRequestsCreatedBeforeAcceptanceManual() async throws { let harness = AllowanceHarness() @@ -507,6 +607,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { private struct AllowanceHarness { static let paymentHash = String(repeating: "ab", count: 32) static let invoice = "lnbcrt10u1allowancetestinvoice" + static let paymentAppId = "bitkit" let log = AllowanceCallLog() let store = AllowanceMemoryStore() @@ -516,9 +617,9 @@ private struct AllowanceHarness { let lookup: AllowanceLightningLookupMock let executor: PaykitAllowanceExecutor - init() { + init(payment: PrivatePaykitAllowancePayment = Self.lightningPayment()) { sdk = AllowanceSdkMock(log: log) - payer = AllowancePayerMock(log: log, payment: Self.lightningPayment()) + payer = AllowancePayerMock(log: log, payment: payment) lookup = AllowanceLightningLookupMock(log: log) let clock = clock executor = PaykitAllowanceExecutor(sdk: sdk, store: store, payer: payer, lightningLookup: lookup, now: { clock.now() }) @@ -532,6 +633,26 @@ private struct AllowanceHarness { ) } + static let onchainAddress = "bcrt1qallowancetestaddress" + + static func onchainPayment() -> PrivatePaykitAllowancePayment { + PrivatePaykitAllowancePayment( + endpoint: PublicPaykitService.Endpoint( + methodId: .bitcoinOnchainP2wpkh, + value: onchainAddress, + min: nil, + max: nil, + rawPayload: "{\"value\":\"\(onchainAddress)\"}" + ), + context: PrivatePaykitPaymentContext( + paymentAppsByEndpoint: [PaykitAllowanceFixtures.onchainIdentifier: paymentAppId], + paymentListVersion: 3 + ), + lightningPaymentHash: nil, + lightningInvoiceHasAmount: false + ) + } + static func lightningPayment() -> PrivatePaykitAllowancePayment { PrivatePaykitAllowancePayment( endpoint: PublicPaykitService.Endpoint( @@ -541,7 +662,10 @@ private struct AllowanceHarness { max: nil, rawPayload: "{\"value\":\"\(invoice)\"}" ), - context: PrivatePaykitPaymentContext(receiverPath: PaykitReceiverPath.wallet, paymentListVersion: 3), + context: PrivatePaykitPaymentContext( + paymentAppsByEndpoint: [PaykitAllowanceFixtures.lightningIdentifier: paymentAppId], + paymentListVersion: 3 + ), lightningPaymentHash: paymentHash, lightningInvoiceHasAmount: true ) @@ -639,6 +763,7 @@ private actor AllowanceLightningLookupMock: PaykitLightningPaymentProofLookingUp private actor AllowancePayerMock: PaykitAllowancePaying { struct PreparedProof: Equatable { + let appId: String let endpoint: String let allowanceId: String? } @@ -651,7 +776,11 @@ private actor AllowancePayerMock: PaykitAllowancePaying { private let log: AllowanceCallLog private let payment: PrivatePaykitAllowancePayment? private var resolveError: Error? + private var claimError: Error? + private var lightningError: Error? private(set) var callCount = 0 + private(set) var claimedRequestIds: [PaykitPaymentRequest.ID] = [] + private(set) var paymentListOutcomes: [PrivatePaymentListSendOutcome] = [] private(set) var preparedProofs: [PreparedProof] = [] private(set) var associatedPaymentHashes: [String] = [] private(set) var lightningPayments: [LightningPayment] = [] @@ -670,19 +799,47 @@ private actor AllowancePayerMock: PaykitAllowancePaying { resolveError = error } + func setClaimError(_ error: Error?) { + claimError = error + } + + func setLightningError(_ error: Error?) { + lightningError = 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 { + func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws { called("consumePaymentList") } - func prepareProof(_ request: PaykitPaymentRequest, paymentEndpointIdentifier: String, allowanceId: String?) async throws { + func resolvePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + outcome: PrivatePaymentListSendOutcome + ) async { + called("resolvePaymentList") + paymentListOutcomes.append(outcome) + } + + func claimForExecution(_ request: PaykitPaymentRequest) async throws { + called("claimForExecution") + if let claimError { throw claimError } + claimedRequestIds.append(request.id) + } + + func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { + called("ensurePaymentAllowed") + } + + func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, allowanceId: String?) async throws { called("prepareProof") - preparedProofs.append(PreparedProof(endpoint: paymentEndpointIdentifier, allowanceId: allowanceId)) + preparedProofs.append(PreparedProof(appId: paymentAppId, endpoint: paymentEndpointIdentifier, allowanceId: allowanceId)) } func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { @@ -697,6 +854,7 @@ private actor AllowancePayerMock: PaykitAllowancePaying { func payLightning(bolt11: String, sats: UInt64?) async throws { called("payLightning") lightningPayments.append(LightningPayment(bolt11: bolt11, sats: sats)) + if let lightningError { throw lightningError } } func payOnchain(address: String, sats: UInt64) async throws -> String { @@ -704,7 +862,7 @@ private actor AllowancePayerMock: PaykitAllowancePaying { return String(repeating: "c", count: 64) } - func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String) async { + func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, paymentEndpointIdentifier: String) async { called("completeOnchainPayment") } @@ -722,7 +880,14 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { static let manualAttemptId = "manual-1" private let log: AllowanceCallLog + struct Proposal { + let counterparty: String + let localRole: Paykit.AllowanceLocalRole + let terms: Paykit.AllowanceTerms + } + private var records: [Paykit.AllowanceRecord] = [] + private var peers: [LinkedPeerRecord] = [] private var accountingState: Paykit.AllowanceAccountingState? = PaykitAllowanceFixtures.accountingState() private var candidates: [Paykit.AllowanceCandidate] = [AllowanceHarness.candidate()] private var automaticReservation: Paykit.PaymentAttemptDecision? @@ -735,6 +900,7 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { private(set) var recordedOutcomes: [Paykit.PaymentOutcomeReport] = [] private(set) var reconciliations: [Paykit.AllowanceAccountingReconciliation] = [] private(set) var manualOnlyOccurrences: [Paykit.PaymentOccurrence] = [] + private(set) var proposals: [Proposal] = [] init(log: AllowanceCallLog) { self.log = log @@ -744,6 +910,10 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { self.records = records } + func setPeers(_ peers: [LinkedPeerRecord]) { + self.peers = peers + } + func setAccountingState(_ state: Paykit.AllowanceAccountingState?) { accountingState = state } @@ -766,7 +936,7 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { func linkedPeers() async throws -> [LinkedPeerRecord] { log.append("linkedPeers") - return [] + return peers } func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] { @@ -776,35 +946,42 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { func proposeAllowance( counterparty: String, - counterpartyReceiverPath: String, localRole: Paykit.AllowanceLocalRole, terms: Paykit.AllowanceTerms ) async throws -> Paykit.AllowanceRecord { log.append("proposeAllowance") - throw AllowanceMockError.unsupported + proposals.append(Proposal(counterparty: counterparty, localRole: localRole, terms: terms)) + let record = PaykitAllowanceFixtures.record( + allowanceId: "proposed-\(proposals.count)", + counterparty: counterparty, + state: .proposed, + terms: terms + ) + records.append(record) + return record } - func acceptAllowance(counterparty: String, counterpartyReceiverPath: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + func acceptAllowance(counterparty: 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 { + func rejectAllowance(counterparty: 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 { + func endAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { log.append("endAllowance") throw AllowanceMockError.unsupported } - func receivePrivateMessages(counterparty: String, counterpartyReceiverPath: String) async throws -> Paykit.PrivateStreamIntakeReport { + func receivePrivateMessages(counterparty: 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 { + func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport { log.append("processOutboundPrivateMessages") return Paykit.OutboundPrivateSendReport(attempted: [], sent: [], failed: [], reservationCleanupFailures: [], recoveryMarkerFailures: []) } diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 7c7283c5d..5467dbe23 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -43,7 +43,6 @@ final class PaykitAllowanceTests: XCTestCase { 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) @@ -239,7 +238,7 @@ final class PaykitAllowanceTests: XCTestCase { 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(allowanceId: Fixtures.otherAllowanceId, sats: 50000, at: "2026-09-10T10:00:00Z"), Fixtures.capacityAttempt(sats: 1000, at: "2026-09-01T00:00:00Z"), ] @@ -297,31 +296,16 @@ final class PaykitAllowanceTests: XCTestCase { // 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 testEntryPrimaryIsTheGrantsAllowance() { + let allowance = Fixtures.allowance(allowanceId: Fixtures.walletAllowanceId) - 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: [allowance], limits: Fixtures.limits) - let entry = PaykitAllowanceEntry(id: "group", allowances: [server, wallet], limits: Fixtures.limits) XCTAssertEqual(entry.primary.allowanceId, Fixtures.walletAllowanceId) + XCTAssertEqual(entry.counterparty, Fixtures.counterpartyKey) XCTAssertEqual(entry.perPaymentMaxSats, 5000) - - let serverOnly = PaykitAllowanceEntry(id: "server", allowances: [server], limits: nil) - XCTAssertEqual(serverOnly.primary.allowanceId, Fixtures.serverAllowanceId) + XCTAssertEqual(entry.monthlyLimitSats, 50000) + XCTAssertEqual(entry.limits, Fixtures.limits) } } @@ -331,7 +315,7 @@ enum PaykitAllowanceFixtures { 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 otherAllowanceId = "allowance-other" static let lightningIdentifier = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue static let onchainIdentifier = PublicPaykitService.MethodId.bitcoinOnchainP2wpkh.rawValue static let now = utc("2026-09-24T12:00:00Z") @@ -372,7 +356,6 @@ enum PaykitAllowanceFixtures { 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, @@ -382,7 +365,6 @@ enum PaykitAllowanceFixtures { ) -> Paykit.AllowanceRecord { Paykit.AllowanceRecord( counterparty: counterparty, - counterpartyReceiverPath: receiverPath, allowanceId: allowanceId, localRole: localRole, state: state, @@ -408,10 +390,31 @@ enum PaykitAllowanceFixtures { ) } + static func contact(publicKey: String = counterpartyKey) -> PubkyContact { + PubkyContact( + publicKey: publicKey, + profile: PubkyProfile(publicKey: publicKey, name: "Alice", bio: "", imageUrl: nil, links: [], status: nil) + ) + } + + static func linkedPeer(counterparty: String = counterpartyKey, state: Paykit.LinkedPeerState) -> Paykit.LinkedPeerRecord { + Paykit.LinkedPeerRecord( + counterparty: counterparty, + state: state, + lastSyncAt: nil, + lastPrivateReceiveAt: nil, + failureCount: 0, + localRecoveryAttemptId: nil, + localRecoveryMarkerCreatedAt: nil, + localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, + remoteRecoveryMarkerObservedAt: 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, @@ -420,7 +423,7 @@ enum PaykitAllowanceFixtures { expiresAt: Date? = nil ) -> PaykitAllowance { PaykitAllowance( - id: PaykitAllowance.ID(counterparty: counterparty, counterpartyReceiverPath: receiverPath, allowanceId: allowanceId), + id: PaykitAllowance.ID(counterparty: counterparty, allowanceId: allowanceId), role: role, lifecycleState: state, isProposedByMe: role == .allower, @@ -469,9 +472,7 @@ enum PaykitAllowanceFixtures { static func accountingScope(_ paymentRequestId: String) -> Paykit.PaymentAccountingScope { Paykit.PaymentAccountingScope( localPublicKey: identityKey, - localReceiverPath: PaykitReceiverPath.wallet, counterparty: counterpartyKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, paymentRequestId: paymentRequestId ) } @@ -507,13 +508,11 @@ enum PaykitAllowanceFixtures { 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, @@ -521,12 +520,19 @@ enum PaykitAllowanceFixtures { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + proposalAppId: "bitkit", + payerAppId: nil, + executionClaimAppId: nil, terms: Paykit.PaymentRequestTerms( amount: Paykit.PaymentRequestAmount(value: amount, asset: PaykitIssuerInterop.bitcoinAsset), paymentReference: Paykit.PaymentReference(text: "invoice-123"), proposalExpiresAt: nil, recurrence: nil, acceptedPaymentEndpointIdentifiers: [lightningIdentifier], + paymentEndpoints: nil, + requiredAppId: "bitkit", + conversion: nil, + paymentDeadline: nil, metadata: Paykit.PrivateJsonObject(text: "{}") ), acceptedEventId: nil, @@ -535,6 +541,7 @@ enum PaykitAllowanceFixtures { rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, + conversionQuotes: [], paymentProofs: [], lastStreamItemId: 1, lastOutboundMessageId: nil, diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 67dcaed48..9ea044255 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -8,10 +8,10 @@ pins the one rule that must never break: a payment interrupted mid-flight is nev ## 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, +contacts and linked, 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 pay the payee's private Lightning invoice first and an on-chain +address otherwise, 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 From 87d7996714cc6ac16ed71fe30c84484e30eea257 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 19:16:03 +0200 Subject: [PATCH 13/17] fix: finish an allowance payment once it starts A cancelled caller no longer stops an automatic payment after the request was accepted: admission runs in its own task once the ledger is reconciled. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 4 +- .../PaykitAllowanceExecutorTests.swift | 105 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 549b82f5c..f9659a666 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -454,7 +454,9 @@ actor PaykitAllowanceExecutor { do { try await ensureReconciled(identity: identity) - return try await admitAndPay(request, allowances: allowances, identity: identity) + // An unstructured task survives a cancelled caller: stopping after the acceptance would leave the request unpaid. + let payment = Task { try await admitAndPay(request, allowances: allowances, identity: identity) } + return try await payment.value } catch { Logger.warn("Automatic allowance payment stayed manual: \(error)", context: "PaykitAllowance") return .manual diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index f95593c47..a6de452c5 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -261,6 +261,51 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertTrue(harness.log.entries.contains("failLightningPayment")) } + // MARK: Cancellation + + func testLightningPaymentStillStartsWhenItsCallerIsCancelledDuringTheSend() async throws { + let harness = AllowanceHarness() + let gate = await harness.payer.holdLightning() + let request = try Fixtures.paymentRequest() + + let caller = Task { await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("payLightning") } + caller.cancel() + gate.open() + let result = await caller.value + + XCTAssertEqual(result, .started) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.sent]) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [], "The settlement event, not the caller, records a Lightning outcome") + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.uncertain]) + } + + func testOnchainPaymentCompletesWhenItsCallerIsCancelledDuringTheSend() async throws { + let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) + let gate = await harness.payer.holdOnchain() + let request = try Fixtures.paymentRequest() + + let caller = Task { await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("payOnchain") } + let sendingStage = await harness.executor.localState(identity: Fixtures.identityKey).journal.map(\.stage) + XCTAssertEqual(sendingStage, [.sending]) + caller.cancel() + gate.open() + let result = await caller.value + + XCTAssertEqual(result, .completed) + XCTAssertTrue(harness.log.entries.contains("completeOnchainPayment")) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .succeeded)]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.succeeded]) + } + // MARK: Settlement and recovery func testLightningSettlementRecordsSuccessForTheJournaledAttempt() async throws { @@ -565,6 +610,13 @@ final class PaykitAllowanceExecutorTests: XCTestCase { // MARK: Helpers + private static func waitUntil(timeout: TimeInterval = 5, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 5_000_000) + } + } + /// 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: [ @@ -739,6 +791,35 @@ private final class AllowanceEventRecorder: @unchecked Sendable { } } +/// A latch a test holds closed to stop a mocked payment mid-send. +private final class AllowanceGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + lock.lock() + if isOpen { + lock.unlock() + continuation.resume() + } else { + waiters.append(continuation) + lock.unlock() + } + } + } + + func open() { + lock.lock() + isOpen = true + let pending = waiters + waiters = [] + lock.unlock() + pending.forEach { $0.resume() } + } +} + private enum AllowanceMockError: Error { case unsupported } @@ -778,6 +859,8 @@ private actor AllowancePayerMock: PaykitAllowancePaying { private var resolveError: Error? private var claimError: Error? private var lightningError: Error? + private var lightningGate: AllowanceGate? + private var onchainGate: AllowanceGate? private(set) var callCount = 0 private(set) var claimedRequestIds: [PaykitPaymentRequest.ID] = [] private(set) var paymentListOutcomes: [PrivatePaymentListSendOutcome] = [] @@ -807,6 +890,19 @@ private actor AllowancePayerMock: PaykitAllowancePaying { lightningError = error } + /// Holds `payLightning` until the returned gate opens, so a test can act while the send is in progress. + func holdLightning() -> AllowanceGate { + let gate = AllowanceGate() + lightningGate = gate + return gate + } + + func holdOnchain() -> AllowanceGate { + let gate = AllowanceGate() + onchainGate = gate + return gate + } + func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? { called("resolve") if let resolveError { throw resolveError } @@ -854,11 +950,20 @@ private actor AllowancePayerMock: PaykitAllowancePaying { func payLightning(bolt11: String, sats: UInt64?) async throws { called("payLightning") lightningPayments.append(LightningPayment(bolt11: bolt11, sats: sats)) + if let lightningGate { + await lightningGate.wait() + // The real node call is cancellable, so a cancelled caller must not be able to stop the payment here. + try Task.checkCancellation() + } if let lightningError { throw lightningError } } func payOnchain(address: String, sats: UInt64) async throws -> String { called("payOnchain") + if let onchainGate { + await onchainGate.wait() + try Task.checkCancellation() + } return String(repeating: "c", count: 64) } From a593f0e4704d23bdf28b9c17778aa1dd96261352 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 19:59:41 +0200 Subject: [PATCH 14/17] fix: keep an auto-accepted request recoverable as a manual payment The shared request flow only shows an accepted request again while this device owns its acceptance. Automatic payments now record that ownership before the automatic Acceptance, so a payment that never reached the node returns as an ordinary Payment Request. --- Bitkit/AppScene.swift | 4 +- Bitkit/Services/PaykitAllowanceExecutor.swift | 43 ++++++++++++++++--- .../PaykitPaymentRequestService.swift | 11 +++++ .../PaykitAllowanceExecutorTests.swift | 41 ++++++++++++++++++ 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 68a74b42d..07780174b 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1133,7 +1133,9 @@ struct AppScene: View { guard let identity = pubkyProfile.publicKey else { return } await paykitPaymentRequestManager.refresh() await paykitAllowanceManager.refresh() - if await paykitAllowanceManager.processIncomingRequests(paykitPaymentRequestManager.pendingRequests) { + let handledAutomatically = await paykitAllowanceManager.processIncomingRequests(paykitPaymentRequestManager.pendingRequests) + let adoptedAcceptances = paykitPaymentRequestManager.reloadAcceptedRequestIds() + if handledAutomatically || adoptedAcceptances { await paykitPaymentRequestManager.refresh() } guard pubkyProfile.authState == .authenticated, diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index f9659a666..2e81246e5 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -122,6 +122,10 @@ protocol PaykitAllowancePaying: Sendable { func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws func resolvePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID, outcome: PrivatePaymentListSendOutcome) async func claimForExecution(_ request: PaykitPaymentRequest) async throws + /// Keeps this device the local owner of the request across the automatic Acceptance, as the manual flow does, so a + /// request that was accepted but never paid comes back to the payer as an ordinary Payment Request. + func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws + func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, allowanceId: String?) async throws func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws @@ -164,6 +168,22 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { try await PaykitPaymentRequestService().claimForPayment(request) } + func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws { + let store = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) + var ids = try store.load(identity: identity) + ids.insert(request.id) + try store.save(ids, identity: identity) + } + + func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async { + let store = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) + do { + try store.save(store.load(identity: identity).subtracting([request.id]), identity: identity) + } catch { + Logger.warn("Failed to discard an allowance acceptance intent: \(error)", context: "PaykitAllowance") + } + } + func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { try await PaykitPaymentRequestService().ensurePaymentAllowed(request) } @@ -505,11 +525,24 @@ actor PaykitAllowanceExecutor { let paymentAppId = try payment.context.paymentAppId(for: endpointIdentifier) // The SDK requires this app to hold the shared execution claim before it queues an automatic Acceptance. try await payer.claimForExecution(request) - 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 payer.recordAcceptanceIntent(request, identity: identity) + let association: Paykit.AllowanceAssociationRecord + do { + association = try await sdk.acceptPaymentRequestAutomatically( + scope: scope, + selection: Paykit.AllowanceSelectionInput(allowanceId: candidate.allowanceId, expectedRevision: nil, trustedTime: selectionTime), + checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: selectionTime) + ) + } catch { + // An interrupted response may follow a committed acceptance, so only a definite refusal drops the intent. + switch error { + case is CancellationError, PaykitError.Transport, PaykitError.Storage, PaykitError.Identity: + break + default: + await payer.discardAcceptanceIntent(request, identity: identity) + } + throw error + } try? await sdk.processOutboundPrivateMessages(counterparty: request.counterparty) let occurrence = Paykit.PaymentOccurrence(request: scope, billingPeriod: nil) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 50d7899e0..5729b96e5 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1517,6 +1517,17 @@ final class PaykitPaymentRequestManager { } } + /// Re-reads the locally accepted request ids after another component saved one, such as the allowance executor. + /// Returns whether the set changed, so the caller refreshes before presenting. + @discardableResult + func reloadAcceptedRequestIds() -> Bool { + guard let identity = activeIdentity, let ids = try? acceptanceStore.load(identity: identity), ids != acceptedRequestIds else { + return false + } + acceptedRequestIds = ids + return true + } + func prepareForPayment( _ request: PaykitPaymentRequest, consumePrivatePaymentList: () async throws -> Void = {} diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index a6de452c5..1a99c6225 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -10,6 +10,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { private static let admissionCalls = [ "evaluateAllowanceCandidates", "claimForExecution", + "recordAcceptanceIntent", "acceptPaymentRequestAutomatically", "reserveAutomaticPayment", "receivePrivateMessages", @@ -71,6 +72,9 @@ final class PaykitAllowanceExecutorTests: XCTestCase { ) let claimed = await harness.payer.claimedRequestIds XCTAssertEqual(claimed, [request.id]) + let intents = await harness.payer.acceptanceIntents + XCTAssertEqual(intents, [request.id], "The request stays this device's own once accepted, so a failed payment comes back as manual") + XCTAssertFalse(harness.log.entries.contains("discardAcceptanceIntent")) let listOutcomes = await harness.payer.paymentListOutcomes XCTAssertEqual(listOutcomes, [.uncertain], "A hand-off to the node keeps the payment list consumed") let associatedHashes = await harness.payer.associatedPaymentHashes @@ -247,6 +251,26 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(log.contains("payLightning")) } + func testRefusedAcceptanceDropsTheIntentButAnInterruptedOneKeepsIt() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + await harness.sdk.setAcceptError(PaykitError.Policy(code: "refused", context: "refused")) + + let refused = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(refused, .manual) + var intents = await harness.payer.acceptanceIntents + XCTAssertEqual(intents, [], "A refused Acceptance leaves nothing to reconcile") + + await harness.sdk.setAcceptError(PaykitError.Transport(code: "timeout", context: "interrupted")) + let interrupted = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(interrupted, .manual) + intents = await harness.payer.acceptanceIntents + XCTAssertEqual(intents, [request.id], "An interrupted response may follow a committed Acceptance") + XCTAssertFalse(harness.log.entries.contains("reserveAutomaticPayment")) + } + func testNodeRejectionBeforeRoutingReleasesThePaymentListAndRecordsAFailure() async throws { let harness = AllowanceHarness() await harness.payer.setLightningError(AllowanceMockError.unsupported) @@ -863,6 +887,7 @@ private actor AllowancePayerMock: PaykitAllowancePaying { private var onchainGate: AllowanceGate? private(set) var callCount = 0 private(set) var claimedRequestIds: [PaykitPaymentRequest.ID] = [] + private(set) var acceptanceIntents: [PaykitPaymentRequest.ID] = [] private(set) var paymentListOutcomes: [PrivatePaymentListSendOutcome] = [] private(set) var preparedProofs: [PreparedProof] = [] private(set) var associatedPaymentHashes: [String] = [] @@ -929,6 +954,16 @@ private actor AllowancePayerMock: PaykitAllowancePaying { claimedRequestIds.append(request.id) } + func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws { + called("recordAcceptanceIntent") + acceptanceIntents.append(request.id) + } + + func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async { + called("discardAcceptanceIntent") + acceptanceIntents.removeAll { $0 == request.id } + } + func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { called("ensurePaymentAllowed") } @@ -998,6 +1033,7 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { private var automaticReservation: Paykit.PaymentAttemptDecision? private var manualReservation: Paykit.PaymentAttemptDecision? private var beginDecision: Paykit.PaymentAttemptDecision? + private var acceptError: Error? private(set) var evaluatedTrustedTimes: [String] = [] private(set) var selections: [Paykit.AllowanceSelectionInput] = [] private(set) var acceptedEndpointIdentifiers: [String] = [] @@ -1039,6 +1075,10 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { beginDecision = decision } + func setAcceptError(_ error: Error?) { + acceptError = error + } + func linkedPeers() async throws -> [LinkedPeerRecord] { log.append("linkedPeers") return peers @@ -1121,6 +1161,7 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { checks: Paykit.PaymentExecutionChecks ) async throws -> Paykit.AllowanceAssociationRecord { log.append("acceptPaymentRequestAutomatically") + if let acceptError { throw acceptError } selections.append(selection) acceptedEndpointIdentifiers.append(checks.paymentEndpointIdentifier) return Paykit.AllowanceAssociationRecord( From 8cac27fee099fbf063206a84cf6874a1d2dbc8f0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 02:07:58 +0200 Subject: [PATCH 15/17] fix: keep a covered request off the send sheet until its automatic payment is decided A request an allowance covers belongs to the automatic flow from the moment it arrives. Before, a slow first pass let the incoming-request presentation open a manual sheet for a request that was then paid automatically. --- Bitkit/Services/PaykitAllowanceManager.swift | 29 +++++++------------ .../PaykitAllowanceExecutorTests.swift | 27 +++++++++++++++++ 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 8c371c9cf..ef9ad2317 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -52,8 +52,6 @@ final class PaykitAllowanceManager { @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, @@ -104,7 +102,6 @@ final class PaykitAllowanceManager { await executor.activate(identity: identity) if identityChanged { manualRequestIds = [:] - waitingRequestIds = [] await executor.recover(identity: identity) } await refresh() @@ -118,7 +115,6 @@ final class PaykitAllowanceManager { autoPaidRequestIds = [] autoPaidSatsByAllowanceId = [:] manualRequestIds = [:] - waitingRequestIds = [] } func refresh() async { @@ -264,14 +260,9 @@ final class PaykitAllowanceManager { 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 - } + let covered = requests.filter(isAwaitingAutomaticPayment) guard !covered.isEmpty else { return false } - guard canPayNow() else { - waitingRequestIds.formUnion(covered.map(\.id)) - return false - } + guard canPayNow() else { return false } isProcessingRequests = true defer { isProcessingRequests = false } @@ -281,14 +272,10 @@ final class PaykitAllowanceManager { switch result { case .started, .completed: handledAny = true - waitingRequestIds.remove(request.id) - case .manual: + case .manual, .notCovered: manualRequestIds[request.id] = signature - waitingRequestIds.remove(request.id) case .deferred: - waitingRequestIds.insert(request.id) - case .notCovered: - waitingRequestIds.remove(request.id) + break } } if handledAny { @@ -297,11 +284,17 @@ final class PaykitAllowanceManager { return handledAny } + /// Whether the allowance flow owns the request, so no sheet may open for it. A covered request belongs to the flow + /// from the moment it arrives, even before the first pass over it, until that pass finds it manual. func isAutomaticallyHandling(_ request: PaykitPaymentRequest) async -> Bool { - if waitingRequestIds.contains(request.id), coversRequest(request) { return true } + if isAwaitingAutomaticPayment(request) { return true } return await executor.isHandling(request.id) } + private func isAwaitingAutomaticPayment(_ request: PaykitPaymentRequest) -> Bool { + request.requiresAcceptance && request.billingPeriod == nil && coversRequest(request) && manualRequestIds[request.id] != allowancesSignature + } + static let acceptanceClockTolerance: TimeInterval = 30 static let allowedPaymentEndpointIdentifiers: [String] = PaykitIssuerInterop.supportedEndpointIdentifiers( diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 1a99c6225..21b53d552 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -138,6 +138,33 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertTrue(harness.log.entries.contains("payLightning")) } + @MainActor + func testManagerOwnsACoveredRequestBeforeTheFirstPassAndReleasesItOnceFoundManual() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + await harness.sdk.setCandidates([AllowanceHarness.candidate(blocked: .sharedRule(code: "amount_outside_range"))]) + let manager = PaykitAllowanceManager( + sdk: harness.sdk, + executor: harness.executor, + now: { PaykitAllowanceFixtures.now }, + canPayNow: { true } + ) + await manager.activate(identity: Fixtures.identityKey) + let covered = try Fixtures.paymentRequest() + let uncovered = try Fixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440002", counterparty: Fixtures.otherCounterpartyKey) + + let ownsBeforeAnyPass = await manager.isAutomaticallyHandling(covered) + let ownsUncovered = await manager.isAutomaticallyHandling(uncovered) + + XCTAssertTrue(ownsBeforeAnyPass, "No sheet may open between the request arriving and the first pass over it") + XCTAssertFalse(ownsUncovered) + + _ = await manager.processIncomingRequests([covered, uncovered]) + + let ownsAfterManual = await manager.isAutomaticallyHandling(covered) + XCTAssertFalse(ownsAfterManual, "A request the pass found manual goes to the Send sheet") + } + @MainActor func testManagerKeepsADeferredRequestOffTheSendSheet() async throws { let harness = AllowanceHarness() From 1cde24a7357a8eca9b45617609b9076b3b614918 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 04:04:23 +0200 Subject: [PATCH 16/17] fix: stop the request presentation from spinning on a request the allowance flow owns Skipping an owned request left it in the presentable list, so the presentation re-entered itself on the main thread until the allowance pass finished. The pass now logs how each covered request ended. --- Bitkit/AppScene.swift | 6 +++++- Bitkit/Services/PaykitAllowanceManager.swift | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index b92ca9324..4d38e284f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1257,7 +1257,11 @@ 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 } + if await paykitAllowanceManager.isAutomaticallyHandling(request) { + // The next refresh presents it if the allowance flow hands it back; presenting again now would spin on it. + shouldPresentNextRequest = false + continue + } do { let result = try await PrivatePaykitService.shared.beginPaymentRequest(request) guard paykitPaymentRequestManager.isCurrentPresentation(request), diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index ef9ad2317..1ce9c1c7f 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -262,13 +262,17 @@ final class PaykitAllowanceManager { let signature = allowancesSignature let covered = requests.filter(isAwaitingAutomaticPayment) guard !covered.isEmpty else { return false } - guard canPayNow() else { return false } + guard canPayNow() else { + Logger.info("Holding \(covered.count) covered request(s) until the node has a usable channel", context: "PaykitAllowance") + return false + } isProcessingRequests = true defer { isProcessingRequests = false } var handledAny = false for request in covered { let result = await executor.autoPay(request, allowances: allowances, identity: identity) + Logger.info("Automatic payment pass for a covered request ended as \(result)", context: "PaykitAllowance") switch result { case .started, .completed: handledAny = true From 0f4ec4e49c596c95b62488d6391501c6516f01b5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 14:41:44 +0200 Subject: [PATCH 17/17] test: pin that allowance windows ignore the subscription clock offset The clock offset from the demo clock PR is in the base now, so the three tests that assert allowance admission, coverage and status keep real time return. --- Bitkit/Services/PaykitAllowance.swift | 2 +- .../PaykitAllowanceExecutorTests.swift | 33 +++++++++++++++++++ BitkitTests/PaykitAllowanceTests.swift | 17 ++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index a2e00bb1d..70c9a27e4 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -2,7 +2,7 @@ import Foundation import Paykit /// An Allowance between this wallet's identity and one contact's identity, built from the SDK record. -/// Eligibility runs on the trusted time passed in by the caller, never on a value read from the allowance itself. +/// Eligibility runs on the trusted time passed in by the caller and never on `SubscriptionClock`, so a clock offset cannot move an allowance window. struct PaykitAllowance: Identifiable, Hashable { struct ID: Codable, Hashable { let counterparty: String diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 21b53d552..75699d666 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -541,6 +541,23 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(payerCalls, 0) } + // MARK: Trusted time vs subscription clock offset + + func testAdmissionUsesInjectedTimeWhileSubscriptionClockIsOffset() async throws { + snapshotAppDefaults(SubscriptionClock.offsetDaysKey) + UserDefaults.standard.set(400, forKey: SubscriptionClock.offsetDaysKey) + try XCTSkipUnless(SubscriptionClock.offsetDays() == 400, "The subscription clock offset 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 subscription clock offset 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 @@ -645,6 +662,22 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(proposals.count, 0) } + @MainActor + func testManagerCoverageUsesInjectedTimeWhileSubscriptionClockIsOffset() async throws { + snapshotAppDefaults(SubscriptionClock.offsetDaysKey) + UserDefaults.standard.set(400, forKey: SubscriptionClock.offsetDaysKey) + try XCTSkipUnless(SubscriptionClock.offsetDays() == 400, "The subscription clock offset 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() diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 5467dbe23..a9d92a295 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -294,6 +294,23 @@ final class PaykitAllowanceTests: XCTestCase { ]) } + // MARK: Trusted time vs subscription clock offset + + func testStatusAndCapacityUseInjectedTimeWhileSubscriptionClockIsOffset() throws { + snapshotAppDefaults(SubscriptionClock.offsetDaysKey) + UserDefaults.standard.set(365, forKey: SubscriptionClock.offsetDaysKey) + try XCTSkipUnless(SubscriptionClock.offsetDays() == 365, "The subscription clock offset is unavailable in this build") + let realNow = Date() + XCTAssertGreaterThan(SubscriptionClock.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 func testEntryPrimaryIsTheGrantsAllowance() {