From 2de9facbb384ecf38e8287d9afe6c4381bffa759 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 24 Sep 2026 11:36:42 +0200 Subject: [PATCH 01/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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() { From 905a345ae282e3dc2fdc5480e71e4bbfc516fc1e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 17:09:25 +0200 Subject: [PATCH 18/46] fix: keep allowances to bitcoin requests and back up their proofs - a request priced in USD or USDT never waits for an automatic payment, and a grant names only bitcoin payment endpoints - a pending automatic payment proof keeps its allowance id through a wallet backup and restore - no automatic payment starts while the profile is being deleted --- Bitkit/AppScene.swift | 3 ++- Bitkit/Models/PaykitPaymentStateBackup.swift | 3 +++ Bitkit/Services/PaykitAllowance.swift | 5 ++++- Bitkit/Services/PaykitAllowanceExecutor.swift | 1 + Bitkit/Services/PaykitAllowanceManager.swift | 5 +++-- .../PaykitAllowanceExecutorTests.swift | 21 ++++++++++++++++++ BitkitTests/PaykitAllowanceTests.swift | 3 ++- .../PaykitPaymentStateBackupTests.swift | 22 +++++++++++++++++++ 8 files changed, 58 insertions(+), 5 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index e1cacda4b..608a3a804 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1420,7 +1420,8 @@ struct AppScene: View { let refreshed = await paykitPaymentRequestManager.refresh( mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, canRefresh: canRefresh ) - if canRefresh(), pubkyProfile.automaticPaykitSession == session { + // Deleting the profile withdraws the payment lists an automatic payment would consume. + if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile { await paykitAllowanceManager.refresh() let handledAutomatically = await paykitAllowanceManager.processIncomingRequests(paykitPaymentRequestManager.pendingRequests) let adoptedAcceptances = paykitPaymentRequestManager.reloadAcceptedRequestIds() diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 22807679c..48763f950 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -77,6 +77,7 @@ struct PaykitPaymentStateBackup: Codable { let proofData: String? let conversionQuoteId: String? let billingPeriod: Period? + let allowanceId: String? let onchainAddress: String? let onchainAmountSats: UInt64? let onchainWalletId: String? @@ -101,6 +102,7 @@ struct PaykitPaymentStateBackup: Codable { proofData = proof.proofData conversionQuoteId = proof.conversionQuoteId billingPeriod = proof.billingPeriod.map { Period(startsAt: $0.sdkValue.startsAt, endsAt: $0.sdkValue.endsAt) } + allowanceId = proof.allowanceId onchainAddress = proof.onchainAddress onchainAmountSats = proof.onchainAmountSats onchainWalletId = proof.onchainWalletId @@ -129,6 +131,7 @@ struct PaykitPaymentStateBackup: Codable { paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind, billingPeriod: period, + allowanceId: allowanceId, paymentStarted: paymentStarted, paymentIdentifier: paymentIdentifier, proofData: proofData, diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index a344335cf..93e1a4a14 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -290,6 +290,9 @@ enum PaykitAllowanceCapacity { } extension PaykitPaymentRequest { - /// The requested amount in sats; allowances cover bitcoin requests only. + /// Allowances cover only requests priced in bitcoin; a USD or USDT request always asks. + var isAllowancePayable: Bool { amount.asset == .btc } + + /// The requested amount in sats, read only for a request where `isAllowancePayable` holds. var allowanceAmountSats: UInt64 { amount.atomic } } diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 93f884d4a..6f0c0d003 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -461,6 +461,7 @@ actor PaykitAllowanceExecutor { ) async -> PaykitAllowanceAutoPayResult { guard request.direction == .incoming, request.billingPeriod == nil, + request.isAllowancePayable, request.lifecycleState == .proposed, !inFlightRequestIds.contains(request.id), allowances.contains(where: { diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 6756eb46b..aebe16072 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -145,7 +145,8 @@ final class PaykitAllowanceManager { /// Whether an incoming request is covered by an active Allowance this wallet granted. A request created before the /// Allowance was accepted stays manual: it may already have been shown to the user for a decision. func coversRequest(_ request: PaykitPaymentRequest) -> Bool { - allowances.contains { allowance in + guard request.isAllowancePayable else { return false } + return allowances.contains { allowance in guard allowance.isAllower, allowance.status(at: now()) == .active, PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty) else { return false } @@ -307,7 +308,7 @@ final class PaykitAllowanceManager { static let acceptanceClockTolerance: TimeInterval = 30 static let allowedPaymentEndpointIdentifiers: [String] = PaykitIssuerInterop.supportedEndpointIdentifiers( - PublicPaykitService.MethodId.publishableMethodIds.map(\.rawValue), + PublicPaykitService.MethodId.publishableMethodIds.filter { $0 != .usdtArbitrum }.map(\.rawValue), network: Env.network ) } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 0e52ab9f0..857aa9e1b 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -607,6 +607,27 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey))) } + @MainActor + func testManagerLeavesRequestsNotPricedInBitcoinManual() 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) + + let usdRequest = try Fixtures.paymentRequest(amount: "5.00", asset: PaykitAsset.usd.rawValue) + XCTAssertFalse(usdRequest.isAllowancePayable) + XCTAssertFalse(manager.coversRequest(usdRequest)) + let isHandling = await manager.isAutomaticallyHandling(usdRequest) + XCTAssertFalse(isHandling, "A USD request reaches the Send sheet instead of waiting for an automatic payment") + } + + @MainActor + func testGrantsNameOnlyBitcoinPaymentEndpoints() { + XCTAssertFalse(PaykitAllowanceManager.allowedPaymentEndpointIdentifiers.contains(PublicPaykitService.MethodId.usdtArbitrum.rawValue)) + XCTAssertTrue(PaykitAllowanceManager.allowedPaymentEndpointIdentifiers.contains(PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue)) + } + // MARK: Granting @MainActor diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index a9d92a295..6b604c75d 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -526,6 +526,7 @@ enum PaykitAllowanceFixtures { id: String = "550e8400-e29b-41d4-a716-446655440001", counterparty: String = counterpartyKey, amount: String = "0.00001", + asset: String = PaykitIssuerInterop.bitcoinAsset, createdAt: String = "2026-09-24T11:00:00Z" ) throws -> PaykitPaymentRequest { let record = try Paykit.PaymentRequestRecord( @@ -541,7 +542,7 @@ enum PaykitAllowanceFixtures { payerAppId: nil, executionClaimAppId: nil, terms: Paykit.PaymentRequestTerms( - amount: Paykit.PaymentRequestAmount(value: amount, asset: PaykitIssuerInterop.bitcoinAsset), + amount: Paykit.PaymentRequestAmount(value: amount, asset: asset), paymentReference: Paykit.PaymentReference(text: "invoice-123"), proposalExpiresAt: nil, recurrence: nil, diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 1b054fd3a..642d6ac35 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -526,6 +526,28 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } } + func testAutomaticAllowancePaymentProofKeepsItsAllowanceIdThroughBackup() throws { + let proof = PendingPaykitPaymentProof( + identity: identity, + requestId: PaykitPaymentRequest.ID(paymentRequestId: "allowance-request", counterparty: identity), + paymentAppId: "bitkit", + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + allowanceId: "allowance-1", + paymentStarted: true, + paymentIdentifier: "payment-hash", + proofData: nil + ) + let encoded = try JSONEncoder().encode(PaykitPaymentStateBackup.Proof(proof)) + let restored = try JSONDecoder().decode(PaykitPaymentStateBackup.Proof.self, from: encoded).restored() + XCTAssertEqual(restored.allowanceId, "allowance-1") + + var legacy = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + legacy.removeValue(forKey: "allowanceId") + let legacyProof = try JSONDecoder().decode(PaykitPaymentStateBackup.Proof.self, from: JSONSerialization.data(withJSONObject: legacy)) + XCTAssertNil(try legacyProof.restored().allowanceId, "Backups written before allowances still restore") + } + func testHardwareBackupRequiresCompleteSignedReceipt() throws { let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let proof = try XCTUnwrap(envelope.paykitPaymentState?.pendingProofs.first) From 1b44a7d137f94e951fcc80e2cc56474177ef4ab2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 17:12:17 +0200 Subject: [PATCH 19/46] fix: keep manual non-bitcoin payments out of the allowance ledger A request priced in USD or USDT paid by hand from an allowance contact is no longer reserved or journaled against the allowance, whose ledger counts sats. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 8 ++++++-- BitkitTests/PaykitAllowanceExecutorTests.swift | 13 +++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 6f0c0d003..19dcf447a 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -745,7 +745,9 @@ 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) async throws -> String? { - guard let identity = activeIdentity, request.billingPeriod == nil, request.direction == .incoming else { return nil } + guard let identity = activeIdentity, request.billingPeriod == nil, request.direction == .incoming, + request.isAllowancePayable + else { return nil } do { try await ensureReconciled(identity: identity) let scope = Paykit.PaymentRequestScope( @@ -826,7 +828,9 @@ actor PaykitAllowanceExecutor { // MARK: Helpers private func checks(_ request: PaykitPaymentRequest, endpointIdentifier: String, trustedTime: String) throws -> Paykit.PaymentExecutionChecks { - try Paykit.PaymentExecutionChecks( + // The ledger counts bitcoin only; a USD or USDT amount must never reach it as sats. + guard request.isAllowancePayable else { throw PaykitAllowanceError.unavailable } + return try Paykit.PaymentExecutionChecks( trustedTime: trustedTime, paymentEndpointIdentifier: endpointIdentifier, actualAmount: Paykit.AccountingAmount(value: request.amountValue, asset: PaykitIssuerInterop.bitcoinAsset), diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 857aa9e1b..2f3419a32 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -457,6 +457,19 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(log.contains("beginPaymentExecution")) } + func testManualPaymentOfARequestNotPricedInBitcoinStaysOutOfTheLedger() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest(amount: "5.00", asset: PaykitAsset.usd.rawValue) + + let attemptId = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier) + + XCTAssertNil(attemptId) + XCTAssertFalse(harness.log.entries.contains("reserveManualPayment")) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal, []) + } + func testManualPaymentIsJournaledAndSubmittedWhenReady() async throws { let harness = AllowanceHarness() await harness.executor.activate(identity: Fixtures.identityKey) From 8edbc4fce8eca9cd6d6a800c9217073f822b854b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 17:15:13 +0200 Subject: [PATCH 20/46] fix: leave bitcoin requests that offer a conversion manual Matches Android: an allowance covers only bitcoin requests without a conversion. --- Bitkit/Services/PaykitAllowance.swift | 4 ++-- BitkitTests/PaykitAllowanceExecutorTests.swift | 4 ++++ BitkitTests/PaykitAllowanceTests.swift | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index 93e1a4a14..05b4b8eb9 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -290,8 +290,8 @@ enum PaykitAllowanceCapacity { } extension PaykitPaymentRequest { - /// Allowances cover only requests priced in bitcoin; a USD or USDT request always asks. - var isAllowancePayable: Bool { amount.asset == .btc } + /// Allowances cover only requests priced in bitcoin without a conversion; any other request always asks. + var isAllowancePayable: Bool { amount.asset == .btc && pricing.conversion == nil } /// The requested amount in sats, read only for a request where `isAllowancePayable` holds. var allowanceAmountSats: UInt64 { amount.atomic } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 2f3419a32..a3284b911 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -633,6 +633,10 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(manager.coversRequest(usdRequest)) let isHandling = await manager.isAutomaticallyHandling(usdRequest) XCTAssertFalse(isHandling, "A USD request reaches the Send sheet instead of waiting for an automatic payment") + + let convertedRequest = try Fixtures.paymentRequest(conversion: .fixed(rates: [.init(asset: "usdt", value: "100000")])) + XCTAssertFalse(convertedRequest.isAllowancePayable, "A bitcoin request that offers a conversion stays manual") + XCTAssertFalse(manager.coversRequest(convertedRequest)) } @MainActor diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 6b604c75d..dd844e333 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -527,6 +527,7 @@ enum PaykitAllowanceFixtures { counterparty: String = counterpartyKey, amount: String = "0.00001", asset: String = PaykitIssuerInterop.bitcoinAsset, + conversion: Paykit.PaymentConversion? = nil, createdAt: String = "2026-09-24T11:00:00Z" ) throws -> PaykitPaymentRequest { let record = try Paykit.PaymentRequestRecord( @@ -549,7 +550,7 @@ enum PaykitAllowanceFixtures { acceptedPaymentEndpointIdentifiers: [lightningIdentifier], paymentEndpoints: nil, requiredAppId: "bitkit", - conversion: nil, + conversion: conversion, paymentDeadline: nil, metadata: Paykit.PrivateJsonObject(text: "{}") ), From 47ed839e2378a5f95c57f3045eb38644fbc5e8eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 18:59:11 +0200 Subject: [PATCH 21/46] fix: count automatic on-chain allowance payments only when accepted Send automatic on-chain payments through the guarded on-chain send flow with the request id, so the payment proof can complete and only an accepted broadcast is recorded as paid. A rejected or unknown broadcast stays unresolved instead of succeeding. Save the sending stage before the node call, so recovery never reads a sent payment as never sent, and notify Limit Reached when the SDK blocks a request above the per-payment limit. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 94 +++++++++++--- .../PaykitAllowanceExecutorTests.swift | 121 +++++++++++++++++- 2 files changed, 192 insertions(+), 23 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 19dcf447a..fa941a79e 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -131,8 +131,9 @@ protocol PaykitAllowancePaying: Sendable { 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, paymentAppId: String, paymentEndpointIdentifier: String) async + /// Throws only before anything was broadcast, after rolling back the started proof. + func payOnchain(_ request: PaykitPaymentRequest, address: String, sats: UInt64, paymentIdentity: String) async throws -> OnchainSendResult + func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, paymentEndpointIdentifier: String) async -> Bool func failLightningPayment(paymentHash: String) async func cancelProofPreparation(_ request: PaykitPaymentRequest) async } @@ -213,12 +214,37 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { _ = 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 payOnchain(_ request: PaykitPaymentRequest, address: String, sats: UInt64, paymentIdentity: String) async throws -> OnchainSendResult { + let feeRate = await max((try? CoreService.shared.blocktank.fees(refresh: false))?.mid ?? 2, 1) + do { + return try await OnchainSendAttemptService.shared.send( + using: LightningService.shared, + address: address, + amountSats: sats, + satsPerVbyte: feeRate, + utxosToSpend: nil, + isMaxAmount: false, + requestId: request.id, + paymentIdentity: paymentIdentity, + followupContext: OnchainSendFollowupContext( + feeSats: 0, feeRate: feeRate, tags: [], contact: request.counterparty, + createdAt: UInt64(Date().timeIntervalSince1970) + ), + paymentDeadline: request.paymentDeadline, + onPreDispatchFailure: { _ in + guard await PaykitPaymentProofService.shared.failOnchainPayment(request, paymentIdentity: paymentIdentity) + else { throw OnchainSendAttemptError.preDispatchCleanupFailed } + } + ) + } catch { + _ = await PaykitPaymentProofService.shared.failOnchainPayment(request, paymentIdentity: paymentIdentity) + throw error + } } - func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, paymentEndpointIdentifier: String) async { + func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, + paymentEndpointIdentifier: String) async -> Bool + { await PaykitPaymentProofService.shared.completeOnchainPayment( request, txid: txid, @@ -500,6 +526,13 @@ actor PaykitAllowanceExecutor { else { let reasons = candidates.compactMap { $0.blocked.map { String(describing: $0) } } Logger.info("No eligible allowance for an incoming request: \(reasons)", context: "PaykitAllowance") + // The SDK blocks a request above the per-payment limit before the capacity check below can notify. + if allowances.contains(where: { + $0.isAllower && $0.lifecycleState == .accepted && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && + $0.perPaymentMaxSats.map { request.allowanceAmountSats > $0 } == true + }) { + notifyLimitReached(request, identity: identity) + } return .manual } @@ -645,6 +678,7 @@ actor PaykitAllowanceExecutor { identity: String ) async throws -> PaykitAllowanceAutoPayResult { do { + try setStageDurably(.sending, attemptId: attemptId, identity: identity) try await payer.associateLightningPayment(request, paymentHash: paymentHash) } catch { await payer.cancelProofPreparation(request) @@ -653,7 +687,6 @@ actor PaykitAllowanceExecutor { throw error } - setStage(.sending, attemptId: attemptId, identity: identity) do { try await payer.payLightning(bolt11: payment.endpoint.value, sats: payment.lightningInvoiceHasAmount ? nil : request.allowanceAmountSats) } catch { @@ -679,6 +712,7 @@ actor PaykitAllowanceExecutor { ) async throws -> PaykitAllowanceAutoPayResult { let address = payment.endpoint.value do { + try setStageDurably(.sending, attemptId: attemptId, identity: identity) try await payer.markOnchainPaymentStarted(request, address: address) } catch { await payer.cancelProofPreparation(request) @@ -687,33 +721,45 @@ actor PaykitAllowanceExecutor { throw error } - setStage(.sending, attemptId: attemptId, identity: identity) - let txid: String + let result: OnchainSendResult do { - txid = try await payer.payOnchain(address: address, sats: request.allowanceAmountSats) + result = try await payer.payOnchain(request, address: address, sats: request.allowanceAmountSats, paymentIdentity: identity) } 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) - } + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) + try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } + let txid: String + switch result { + case let .accepted(acceptedTxid): + txid = acceptedTxid + case .rejected, .unknown: + // The durable send attempt keeps the request from being paid again until its outcome resolves. + Logger.warn("Automatic on-chain allowance payment outcome is uncertain: \(result)", context: "PaykitAllowance") + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) + try await record(attemptId: attemptId, outcome: .unknown, identity: identity) + return .started + } + + // Recovery reads a journaled transaction as paid, so only an accepted broadcast is journaled. updateLocalState(identity: identity) { state in guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } state.journal[index].transactionId = txid } await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .succeeded) - await payer.completeOnchainPayment( + let proofSaved = await payer.completeOnchainPayment( request, txid: txid, paymentAppId: paymentAppId, paymentEndpointIdentifier: payment.endpoint.methodId.rawValue ) + guard proofSaved else { + // The proof service retries the accepted send's proof; recovery records the payment once it settles. + Logger.warn("Automatic on-chain allowance payment proof is not saved yet", context: "PaykitAllowance") + try await record(attemptId: attemptId, outcome: .unknown, identity: identity) + return .started + } try await record(attemptId: attemptId, outcome: .succeeded, identity: identity) Self.eventSubject.send(.paidAutomatically(counterparty: request.counterparty, amountSats: request.allowanceAmountSats, paymentId: txid)) return .completed @@ -850,6 +896,16 @@ actor PaykitAllowanceExecutor { } } + /// Recovery reads an entry still at `.submitted` as never sent, so the send must not start before `.sending` is saved. + private func setStageDurably(_ stage: PaykitAllowanceLocalState.Stage, attemptId: String, identity: String) throws { + var state = try store.load(identity: identity) + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { + throw PaykitAllowanceError.unavailable + } + state.journal[index].stage = stage + try store.save(state, identity: identity) + } + 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 } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index a3284b911..04b08984e 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1,6 +1,7 @@ @testable import Bitkit import Combine import Foundation +import LDKNode import Paykit import XCTest @@ -222,6 +223,37 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(limitEvents.count, 1) } + func testRequestAbovePerPaymentLimitNotifiesOnceWhenTheSdkBlocksIt() async throws { + let harness = AllowanceHarness() + await harness.sdk.setCandidates([AllowanceHarness.candidate(blocked: .sharedRule(code: "per_payment_amount_exceeded"))]) + let request = try Fixtures.paymentRequest() + let allowance = Fixtures.allowance(perPaymentMaxSats: 500) + let events = AllowanceEventRecorder() + + let first = await harness.executor.autoPay(request, allowances: [allowance], identity: Fixtures.identityKey) + let second = await harness.executor.autoPay(request, allowances: [allowance], identity: Fixtures.identityKey) + + XCTAssertEqual(first, .manual) + XCTAssertEqual(second, .manual) + let limitEvents = events.events.filter { $0 == .limitReached(counterparty: Fixtures.counterpartyKey, amountSats: 1000) } + XCTAssertEqual(limitEvents.count, 1) + } + + func testPaymentIsNotSentWhenTheSendingStageCannotBeSaved() async throws { + let harness = AllowanceHarness() + harness.store.failSaves() + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + XCTAssertFalse(harness.log.entries.contains("payLightning")) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)]) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) + } + func testBlockedReservationMarksThePaymentManualOnly() async throws { let harness = AllowanceHarness() await harness.sdk.setAutomaticReservation(.blocked(reason: .sharedRule(code: "period_amount_limit_exceeded"))) @@ -357,6 +389,58 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(listOutcomes, [.succeeded]) } + func testRejectedOnchainBroadcastStaysUncertainWithoutJournalingTheTransaction() async throws { + let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) + await harness.payer.setOnchainResult(.rejected(txid: AllowanceHarness.onchainTxid, reason: "mempool conflict")) + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .started) + XCTAssertFalse(harness.log.entries.contains("completeOnchainPayment")) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .unknown)]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.unknown]) + XCTAssertEqual(journal.map(\.transactionId), [nil], "Recovery reads a journaled transaction as paid") + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.uncertain]) + } + + func testOnchainSendFailureBeforeBroadcastRecordsFailure() async throws { + let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) + await harness.payer.setOnchainError(OnchainSendAttemptError.preDispatch(AllowanceMockError.unsupported)) + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, 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 listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) + } + + func testAcceptedOnchainPaymentWithoutSavedProofIsNotRecordedAsSucceeded() async throws { + let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) + await harness.payer.setOnchainProofSaved(false) + let request = try Fixtures.paymentRequest() + let events = AllowanceEventRecorder() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .started) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .unknown)]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.unknown]) + XCTAssertEqual(journal.map(\.transactionId), [AllowanceHarness.onchainTxid]) + let paidEvents = events.events.filter { + $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: AllowanceHarness.onchainTxid) + } + XCTAssertEqual(paidEvents, []) + } + // MARK: Settlement and recovery func testLightningSettlementRecordsSuccessForTheJournaledAttempt() async throws { @@ -809,6 +893,8 @@ private struct AllowanceHarness { static let onchainAddress = "bcrt1qallowancetestaddress" + static let onchainTxid = String(repeating: "c", count: 64) + static func onchainPayment() -> PrivatePaykitAllowancePayment { PrivatePaykitAllowancePayment( endpoint: PublicPaykitService.Endpoint( @@ -879,13 +965,21 @@ private final class AllowanceTestClock: @unchecked Sendable { private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sendable { private let lock = NSLock() private var states: [String: PaykitAllowanceLocalState] = [:] + private var savesFail = false func load(identity: String) throws -> PaykitAllowanceLocalState { lock.withLock { states[identity] ?? PaykitAllowanceLocalState() } } func save(_ state: PaykitAllowanceLocalState, identity: String) throws { - lock.withLock { states[identity] = state } + try lock.withLock { + if savesFail { throw AllowanceMockError.unsupported } + states[identity] = state + } + } + + func failSaves() { + lock.withLock { savesFail = true } } func seed(_ state: PaykitAllowanceLocalState, identity: String) { @@ -983,6 +1077,9 @@ private actor AllowancePayerMock: PaykitAllowancePaying { private var lightningError: Error? private var lightningGate: AllowanceGate? private var onchainGate: AllowanceGate? + private var onchainResult: OnchainSendResult = .accepted(txid: AllowanceHarness.onchainTxid) + private var onchainError: Error? + private var onchainProofSaved = true private(set) var callCount = 0 private(set) var claimedRequestIds: [PaykitPaymentRequest.ID] = [] private(set) var acceptanceIntents: [PaykitPaymentRequest.ID] = [] @@ -1013,6 +1110,18 @@ private actor AllowancePayerMock: PaykitAllowancePaying { lightningError = error } + func setOnchainResult(_ result: OnchainSendResult) { + onchainResult = result + } + + func setOnchainError(_ error: Error?) { + onchainError = error + } + + func setOnchainProofSaved(_ saved: Bool) { + onchainProofSaved = saved + } + /// Holds `payLightning` until the returned gate opens, so a test can act while the send is in progress. func holdLightning() -> AllowanceGate { let gate = AllowanceGate() @@ -1091,17 +1200,21 @@ private actor AllowancePayerMock: PaykitAllowancePaying { if let lightningError { throw lightningError } } - func payOnchain(address: String, sats: UInt64) async throws -> String { + func payOnchain(_ request: PaykitPaymentRequest, address: String, sats: UInt64, paymentIdentity: String) async throws -> OnchainSendResult { called("payOnchain") if let onchainGate { await onchainGate.wait() try Task.checkCancellation() } - return String(repeating: "c", count: 64) + if let onchainError { throw onchainError } + return onchainResult } - func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, paymentEndpointIdentifier: String) async { + func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, + paymentEndpointIdentifier: String) async -> Bool + { called("completeOnchainPayment") + return onchainProofSaved } func failLightningPayment(paymentHash: String) async { From 466e1d84ebe6d72140de8aca8dc748e5475454ea Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 19:10:52 +0200 Subject: [PATCH 22/46] feat: ask for the payment PIN before granting or ending an allowance When a PIN is required for payments, the payer's Save, Accept and End ask for the same PIN or biometric check as a payment. Automatic payments never prompt: the checked grant authorises them. --- .../Views/Security/PaymentAuthorization.swift | 74 +++++++++++++++++++ .../Views/Subscriptions/AllowancesView.swift | 17 +++++ journeys/allowances/README.md | 2 + journeys/allowances/pin-gates-allowance.xml | 20 +++++ 4 files changed, 113 insertions(+) create mode 100644 Bitkit/Views/Security/PaymentAuthorization.swift create mode 100644 journeys/allowances/pin-gates-allowance.xml diff --git a/Bitkit/Views/Security/PaymentAuthorization.swift b/Bitkit/Views/Security/PaymentAuthorization.swift new file mode 100644 index 000000000..54b01549b --- /dev/null +++ b/Bitkit/Views/Security/PaymentAuthorization.swift @@ -0,0 +1,74 @@ +import SwiftUI + +/// Asks for the same PIN or biometric check as a payment when the user requires one for payments. +@MainActor +@Observable +final class PaymentAuthorization { + fileprivate(set) var isAskingForPin = false + fileprivate(set) var biometricErrorMessage: String? + @ObservationIgnored private var pinContinuation: CheckedContinuation? + + func authorize(settings: SettingsViewModel) async -> Bool { + guard settings.requirePinForPayments && settings.pinEnabled else { return true } + if settings.useBiometrics && BiometricAuth.isAvailable { + switch await BiometricAuth.authenticate() { + case .success: + return true + case .cancelled: + return false + case let .failed(message): + biometricErrorMessage = message + return false + } + } + resolvePin(false) + isAskingForPin = true + return await withCheckedContinuation { pinContinuation = $0 } + } + + fileprivate func resolvePin(_ approved: Bool) { + isAskingForPin = false + let continuation = pinContinuation + pinContinuation = nil + continuation?.resume(returning: approved) + } + + fileprivate func dismissBiometricError() { + biometricErrorMessage = nil + } +} + +private struct PaymentAuthorizationModifier: ViewModifier { + let authorization: PaymentAuthorization + + func body(content: Content) -> some View { + content + .sheet(isPresented: Binding( + get: { authorization.isAskingForPin }, + set: { if !$0 { authorization.resolvePin(false) } } + )) { + SendPinScreen( + onCancel: { authorization.resolvePin(false) }, + onPinVerified: { authorization.resolvePin(true) } + ) + } + .alert( + t("security__bio_error_title"), + isPresented: Binding( + get: { authorization.biometricErrorMessage != nil }, + set: { if !$0 { authorization.dismissBiometricError() } } + ) + ) { + Button(t("common__ok")) {} + } message: { + Text(authorization.biometricErrorMessage ?? "") + } + .onDisappear { authorization.resolvePin(false) } + } +} + +extension View { + func paymentAuthorization(_ authorization: PaymentAuthorization) -> some View { + modifier(PaymentAuthorizationModifier(authorization: authorization)) + } +} diff --git a/Bitkit/Views/Subscriptions/AllowancesView.swift b/Bitkit/Views/Subscriptions/AllowancesView.swift index 6850d3882..5cba7c26e 100644 --- a/Bitkit/Views/Subscriptions/AllowancesView.swift +++ b/Bitkit/Views/Subscriptions/AllowancesView.swift @@ -371,7 +371,9 @@ struct AllowanceContactView: View { struct SetAllowanceView: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var settings: SettingsViewModel @Environment(PaykitAllowanceManager.self) private var allowances + @State private var authorization = PaymentAuthorization() let contact: PubkyContact let onBack: () -> Void @@ -444,6 +446,7 @@ struct SetAllowanceView: View { .padding(.horizontal, 16) .accessibilityElement(children: .contain) .accessibilityIdentifier("SetAllowance") + .paymentAuthorization(authorization) } private func save() async { @@ -459,6 +462,7 @@ struct SetAllowanceView: View { perPaymentSats: perPaymentSats, monthlySats: monthlySats ) + guard await authorization.authorize(settings: settings) else { return } do { try await allowances.propose(to: contact, limits: limits) onSaved() @@ -579,7 +583,9 @@ struct AllowanceReviewView: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var settings: SettingsViewModel @Environment(PaykitAllowanceManager.self) private var allowances + @State private var authorization = PaymentAuthorization() let entry: PaykitAllowanceEntry @@ -634,6 +640,7 @@ struct AllowanceReviewView: View { .padding(.horizontal, 16) .accessibilityElement(children: .contain) .accessibilityIdentifier("AllowanceReview") + .paymentAuthorization(authorization) .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)) @@ -643,6 +650,10 @@ struct AllowanceReviewView: View { } private func respond(accept: Bool) async { + // Accepting as the payer grants spending power, like a payment. + if accept, entry.role == .allower { + guard await authorization.authorize(settings: settings) else { return } + } do { if accept { try await allowances.accept(entry) @@ -662,7 +673,9 @@ struct AllowanceDetailView: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var settings: SettingsViewModel @Environment(PaykitAllowanceManager.self) private var allowances + @State private var authorization = PaymentAuthorization() let entry: PaykitAllowanceEntry @@ -703,6 +716,9 @@ struct AllowanceDetailView: View { accentColor: .purpleAccent, isLoading: allowances.isWorking ) { + if current.role == .allower { + guard await authorization.authorize(settings: settings) else { throw CancellationError() } + } do { try await allowances.end(current) sheets.hideSheet(reason: "Allowance ended") @@ -717,6 +733,7 @@ struct AllowanceDetailView: View { .padding(.horizontal, 16) .accessibilityElement(children: .contain) .accessibilityIdentifier("AllowanceDetail") + .paymentAuthorization(authorization) } private var explanation: String { diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 9ea044255..0da2afb3e 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -4,6 +4,8 @@ Cover the Paykit allowance lifecycle between two Bitkit instances: the payer set 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. +The PIN journey checks that the payer's grant, accept and end ask for the payment PIN when the +user requires one, while automatic payments never prompt. ## Setup diff --git a/journeys/allowances/pin-gates-allowance.xml b/journeys/allowances/pin-gates-allowance.xml new file mode 100644 index 000000000..b630ad5ed --- /dev/null +++ b/journeys/allowances/pin-gates-allowance.xml @@ -0,0 +1,20 @@ + + + With a PIN set and "Require PIN for payments" on, the payer's allowance actions ask for the same + PIN or biometric check as a payment: saving a new allowance, accepting a requested one and ending + one. Cancelling the check leaves the allowance unchanged. The payee's answers never ask, and + automatic payments under an Active allowance never prompt: the checked grant authorises them. + + + Launch the E2E Bitkit app on two instances with linked Paykit contacts, per set-and-accept.xml + On the payer instance, open Settings, Security, set a PIN, and turn on "Require PIN for payments" (id "EnablePinForPayments") with biometrics off + On the payer instance, open Subscriptions, the Allowances tab, choose the payee and set a $5 a payment, $50 a month allowance; tap Save (id "AllowanceSave") + Verify the PIN entry opens instead of the allowance being proposed; go back + Verify the Set Allowance sheet (id "SetAllowance") is still shown and the Allowances list has no row for the payee + Tap Save again, enter the correct PIN, and verify the sheet dismisses and the payee's row reads as waiting for an answer + On the payee instance, accept the allowance from its review sheet (id "AllowanceReview") and verify no PIN entry opens + On the payee instance, send the payer a one-time Payment Request for $2 and verify the payer pays it automatically with no PIN entry, per auto-pay-under-limit.xml + On the payer instance, open the payee's row, swipe "Swipe To End Allowance", go back from the PIN entry, and verify the detail (id "AllowanceDetail") still shows the swipe control and the row is still Active + Swipe again, enter the correct PIN, and verify the row reads "Ended" + + From e2c84e47a20a4efc684a6cbad8aa22d79d111654 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 19:25:36 +0200 Subject: [PATCH 23/46] fix: keep uncertain automatic on-chain sends from being paid again Only a send that never reached dispatch records the attempt as failed and rolls back its proof; any other send error records it as unknown, and a request that would pay on-chain stays manual while an earlier on-chain send is unresolved. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 26 ++++++++++--- .../PaykitAllowanceExecutorTests.swift | 38 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index fa941a79e..66b1448f3 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -131,7 +131,9 @@ protocol PaykitAllowancePaying: Sendable { func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String) async throws func payLightning(bolt11: String, sats: UInt64?) async throws - /// Throws only before anything was broadcast, after rolling back the started proof. + /// Whether an earlier on-chain send is unresolved, so another one cannot start. + func isOnchainSendBlocked() async -> Bool + /// Throws `OnchainSendAttemptError.preDispatch` only when nothing was broadcast, after rolling back the started proof. func payOnchain(_ request: PaykitPaymentRequest, address: String, sats: UInt64, paymentIdentity: String) async throws -> OnchainSendResult func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, paymentEndpointIdentifier: String) async -> Bool func failLightningPayment(paymentHash: String) async @@ -214,6 +216,10 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { _ = try await LightningService.shared.send(bolt11: bolt11, sats: sats) } + func isOnchainSendBlocked() async -> Bool { + (try? await OnchainSendAttemptService.shared.blockingAttempt()) != nil + } + func payOnchain(_ request: PaykitPaymentRequest, address: String, sats: UInt64, paymentIdentity: String) async throws -> OnchainSendResult { let feeRate = await max((try? CoreService.shared.blocktank.fees(refresh: false))?.mid ?? 2, 1) do { @@ -236,9 +242,9 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { else { throw OnchainSendAttemptError.preDispatchCleanupFailed } } ) - } catch { + } catch let OnchainSendAttemptError.preDispatch(underlying) { _ = await PaykitPaymentProofService.shared.failOnchainPayment(request, paymentIdentity: paymentIdentity) - throw error + throw OnchainSendAttemptError.preDispatch(underlying) } } @@ -554,6 +560,10 @@ actor PaykitAllowanceExecutor { Logger.info("No payable private endpoint for an allowance payment; leaving it manual", context: "PaykitAllowance") return .manual } + if payment.lightningPaymentHash == nil, await payer.isOnchainSendBlocked() { + Logger.info("An earlier on-chain send is unresolved; leaving the request manual", context: "PaykitAllowance") + return .manual + } let endpointIdentifier = payment.endpoint.methodId.rawValue let paymentAppId = try payment.context.paymentAppId(for: endpointIdentifier) @@ -724,10 +734,16 @@ actor PaykitAllowanceExecutor { let result: OnchainSendResult do { result = try await payer.payOnchain(request, address: address, sats: request.allowanceAmountSats, paymentIdentity: identity) - } catch { + } catch let OnchainSendAttemptError.preDispatch(underlying) { await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) - throw error + throw OnchainSendAttemptError.preDispatch(underlying) + } catch { + // Another live attempt for this request, or an outcome that was not saved, may have paid it. + Logger.warn("Automatic on-chain allowance payment outcome is uncertain: \(error)", context: "PaykitAllowance") + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) + try await record(attemptId: attemptId, outcome: .unknown, identity: identity) + return .started } let txid: String diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 04b08984e..7019840c1 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -421,6 +421,34 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) } + func testOnchainSendErrorAfterAdmissionStaysUncertain() async throws { + let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) + await harness.payer.setOnchainError(OnchainSendAttemptError.outcomeNotSaved) + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .started) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .unknown)]) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.uncertain]) + } + + func testOnchainRequestStaysManualWhileAnEarlierSendIsUnresolved() async throws { + let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) + await harness.payer.setOnchainSendBlocked(true) + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + XCTAssertFalse(harness.log.entries.contains("claimForExecution")) + XCTAssertFalse(harness.log.entries.contains("payOnchain")) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, []) + } + func testAcceptedOnchainPaymentWithoutSavedProofIsNotRecordedAsSucceeded() async throws { let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) await harness.payer.setOnchainProofSaved(false) @@ -1080,6 +1108,7 @@ private actor AllowancePayerMock: PaykitAllowancePaying { private var onchainResult: OnchainSendResult = .accepted(txid: AllowanceHarness.onchainTxid) private var onchainError: Error? private var onchainProofSaved = true + private var onchainSendBlocked = false private(set) var callCount = 0 private(set) var claimedRequestIds: [PaykitPaymentRequest.ID] = [] private(set) var acceptanceIntents: [PaykitPaymentRequest.ID] = [] @@ -1122,6 +1151,15 @@ private actor AllowancePayerMock: PaykitAllowancePaying { onchainProofSaved = saved } + func setOnchainSendBlocked(_ blocked: Bool) { + onchainSendBlocked = blocked + } + + func isOnchainSendBlocked() async -> Bool { + called("isOnchainSendBlocked") + return onchainSendBlocked + } + /// Holds `payLightning` until the returned gate opens, so a test can act while the send is in progress. func holdLightning() -> AllowanceGate { let gate = AllowanceGate() From efb6ffe1f88c791b6ea80c55cb37d81004892993 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 19:25:36 +0200 Subject: [PATCH 24/46] test: port the allowance PIN journey from android --- .../pin-gates-allowance-changes.xml | 29 +++++++++++++++++++ journeys/allowances/pin-gates-allowance.xml | 20 ------------- 2 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 journeys/allowances/pin-gates-allowance-changes.xml delete mode 100644 journeys/allowances/pin-gates-allowance.xml diff --git a/journeys/allowances/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml new file mode 100644 index 000000000..087725b8d --- /dev/null +++ b/journeys/allowances/pin-gates-allowance-changes.xml @@ -0,0 +1,29 @@ + + + When the payer requires a PIN for payments, setting an allowance and ending it ask for the same + PIN (or biometrics) as Send before anything changes, and a cancelled check changes nothing. The + payee's answers never ask. Automatic payments under an accepted allowance never ask: the checked + grant is their authorisation. + + Precondition: the two-instance setup in README.md, with no allowance between them yet. On the + payer, enable a PIN you choose under Settings ▸ Security ▸ PIN Code and turn on "Also require for + payments"; no biometrics enrolled on the simulator, so the PIN pad shows directly. Never enter a + wrong PIN more than once. + + iOS difference: the PIN pad is Send's PIN screen and opens as a sheet over the allowance sheet; + "press back" means its back button. + + + On the payer instance, open Subscriptions, tap the Allowances tab, tap Add (id "AllowanceAdd"), pick the payee and keep $5 a payment and $50 a month + Tap Save Allowance (id "AllowanceSave") + Verify the PIN pad shows over the sheet, then press back + Verify the Set sheet (id "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no review sheet + Tap Save Allowance again and enter the PIN + Verify the sheet dismisses and the payer's row reads "Waiting for an answer" + On the payee instance, verify the review sheet (id "AllowanceReview") opens on its own, tap Accept (id "AllowanceAccept") and verify no PIN is asked + On the payee instance, send the payer a one-time Payment Request for $2 and verify the payer pays it automatically with no PIN pad, per auto-pay-under-limit.xml + On the payer instance, tap the payee's row and swipe "Swipe To End Allowance" to the end + Verify the PIN pad shows, press back, and verify the detail sheet (id "AllowanceDetail") is back with the swipe control at its start and the allowance still Active + Swipe it to the end again, enter the PIN, and verify the row reads "Ended · $2.00 paid automatically" + + diff --git a/journeys/allowances/pin-gates-allowance.xml b/journeys/allowances/pin-gates-allowance.xml deleted file mode 100644 index b630ad5ed..000000000 --- a/journeys/allowances/pin-gates-allowance.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - With a PIN set and "Require PIN for payments" on, the payer's allowance actions ask for the same - PIN or biometric check as a payment: saving a new allowance, accepting a requested one and ending - one. Cancelling the check leaves the allowance unchanged. The payee's answers never ask, and - automatic payments under an Active allowance never prompt: the checked grant authorises them. - - - Launch the E2E Bitkit app on two instances with linked Paykit contacts, per set-and-accept.xml - On the payer instance, open Settings, Security, set a PIN, and turn on "Require PIN for payments" (id "EnablePinForPayments") with biometrics off - On the payer instance, open Subscriptions, the Allowances tab, choose the payee and set a $5 a payment, $50 a month allowance; tap Save (id "AllowanceSave") - Verify the PIN entry opens instead of the allowance being proposed; go back - Verify the Set Allowance sheet (id "SetAllowance") is still shown and the Allowances list has no row for the payee - Tap Save again, enter the correct PIN, and verify the sheet dismisses and the payee's row reads as waiting for an answer - On the payee instance, accept the allowance from its review sheet (id "AllowanceReview") and verify no PIN entry opens - On the payee instance, send the payer a one-time Payment Request for $2 and verify the payer pays it automatically with no PIN entry, per auto-pay-under-limit.xml - On the payer instance, open the payee's row, swipe "Swipe To End Allowance", go back from the PIN entry, and verify the detail (id "AllowanceDetail") still shows the swipe control and the row is still Active - Swipe again, enter the correct PIN, and verify the row reads "Ended" - - From ab432601747d73c8f1b76afef7997baba7af036c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 20:08:23 +0200 Subject: [PATCH 25/46] fix: pay automatic allowance requests with the manual flow's guards Resolve the payee's endpoint through the request, so its required app and bound destination apply, and allow request-bound endpoints without a payment list version. Check the request's payment deadline when the Lightning payment is submitted, keep a submission error that may follow a started payment unknown with its proof and reservation, and never replace a stage that settlement already recorded. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 47 +++++++++++++++---- .../PrivatePaykitService+Payments.swift | 8 ++-- .../PaykitAllowanceExecutorTests.swift | 40 +++++++++++++++- 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 66b1448f3..902301a66 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -60,6 +60,10 @@ struct PaykitAllowanceLocalState: Codable, Equatable { case succeeded case failed case unknown + + var isTerminal: Bool { + self == .succeeded || self == .failed + } } struct JournalEntry: Codable, Equatable { @@ -130,13 +134,14 @@ protocol PaykitAllowancePaying: Sendable { 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 payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws /// Whether an earlier on-chain send is unresolved, so another one cannot start. func isOnchainSendBlocked() async -> Bool /// Throws `OnchainSendAttemptError.preDispatch` only when nothing was broadcast, after rolling back the started proof. func payOnchain(_ request: PaykitPaymentRequest, address: String, sats: UInt64, paymentIdentity: String) async throws -> OnchainSendResult func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, paymentEndpointIdentifier: String) async -> Bool - func failLightningPayment(paymentHash: String) async + /// Removes the proof only when the submission error proves nothing reached the node; returns whether it did. + func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool func cancelProofPreparation(_ request: PaykitPaymentRequest) async } @@ -212,8 +217,12 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { 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 payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws { + _ = try await LightningService.shared.send( + bolt11: bolt11, + sats: sats, + beforeSubmission: { try PaykitPaymentRequest.checkPaymentDeadline(request.paymentDeadline) } + ) } func isOnchainSendBlocked() async -> Bool { @@ -259,8 +268,8 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { ) } - func failLightningPayment(paymentHash: String) async { - await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) + func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash, submissionError: submissionError) } func cancelProofPreparation(_ request: PaykitPaymentRequest) async { @@ -698,10 +707,21 @@ actor PaykitAllowanceExecutor { } do { - try await payer.payLightning(bolt11: payment.endpoint.value, sats: payment.lightningInvoiceHasAmount ? nil : request.allowanceAmountSats) + try await payer.payLightning( + request, + bolt11: payment.endpoint.value, + sats: payment.lightningInvoiceHasAmount ? nil : request.allowanceAmountSats + ) } catch { - // LDK rejected the payment before routing it. - await payer.failLightningPayment(paymentHash: paymentHash) + guard await payer.failLightningPayment(paymentHash: paymentHash, submissionError: error) else { + // The node may have started the payment; its settlement event records the outcome. + Logger.warn("Automatic Lightning allowance payment outcome is uncertain: \(error)", context: "PaykitAllowance") + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) + if !hasTerminalStage(attemptId: attemptId, identity: identity) { + try await record(attemptId: attemptId, outcome: .unknown, identity: identity) + } + return .started + } await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error @@ -922,13 +942,20 @@ actor PaykitAllowanceExecutor { try store.save(state, identity: identity) } + /// Settlement can record an outcome while a send is still cleaning up, so a later stage never replaces it. 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 } + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }), + !state.journal[index].stage.isTerminal + else { return } state.journal[index].stage = stage } } + private func hasTerminalStage(attemptId: String, identity: String) -> Bool { + localState(identity: identity).journal.first { $0.attemptId == attemptId }?.stage.isTerminal == true + } + private func notifyLimitReached(_ request: PaykitPaymentRequest, identity: String) { var isNew = false updateLocalState(identity: identity) { state in diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index fa92f1558..b09569310 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -523,9 +523,10 @@ extension PrivatePaykitService { else { return nil } let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersion - let prepared = try await PaykitSdkService.shared.prepareAndResolvePrivateContactPayment( + // Resolve through the request, as the manual flow does, so its required app and bound destination apply. + let prepared = try await PaykitSdkService.shared.prepareAndResolvePrivatePaymentRequest( counterparty: publicKey, - amount: PaymentAmountContext(value: request.amountValue, asset: PaykitIssuerInterop.bitcoinAsset), + paymentRequestId: request.paymentRequestId, afterPrivatePaymentListVersion: consumedVersion ) if prepared.resolution.state == .recoveryPending || prepared.resolution.status == .waitingForUpdatedPaymentList { @@ -533,7 +534,8 @@ extension PrivatePaykitService { schedulePrivatePaymentRecovery(for: publicKey) throw PaykitAllowanceError.paymentListPending } - guard let paymentListVersion = prepared.resolution.privatePaymentListVersion else { return nil } + // Request-bound endpoints carry no payment list version, so there is no list to consume for them. + let paymentListVersion = prepared.resolution.privatePaymentListVersion let eligible = Set(eligibleIdentifiers).intersection(request.acceptedPaymentEndpointIdentifiers) let candidates = resolvedEndpoints(from: prepared.resolution).filter { diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 7019840c1..c9bafc364 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -344,6 +344,36 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertTrue(harness.log.entries.contains("failLightningPayment")) } + func testUncertainLightningSubmissionErrorKeepsTheReservation() async throws { + let harness = AllowanceHarness() + await harness.payer.setLightningError(AllowanceMockError.unsupported) + await harness.payer.setLightningErrorIsDefinite(false) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .started) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.uncertain]) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .unknown)]) + } + + func testSettlementDuringTheSendIsNotReplacedBySent() async throws { + let harness = AllowanceHarness() + let gate = await harness.payer.holdLightning() + let request = try Fixtures.paymentRequest() + await harness.executor.activate(identity: Fixtures.identityKey) + + let caller = Task { await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("payLightning") } + await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: true) + gate.open() + _ = await caller.value + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + } + // MARK: Cancellation func testLightningPaymentStillStartsWhenItsCallerIsCancelledDuringTheSend() async throws { @@ -1109,6 +1139,7 @@ private actor AllowancePayerMock: PaykitAllowancePaying { private var onchainError: Error? private var onchainProofSaved = true private var onchainSendBlocked = false + private var lightningErrorIsDefinite = true private(set) var callCount = 0 private(set) var claimedRequestIds: [PaykitPaymentRequest.ID] = [] private(set) var acceptanceIntents: [PaykitPaymentRequest.ID] = [] @@ -1151,6 +1182,10 @@ private actor AllowancePayerMock: PaykitAllowancePaying { onchainProofSaved = saved } + func setLightningErrorIsDefinite(_ definite: Bool) { + lightningErrorIsDefinite = definite + } + func setOnchainSendBlocked(_ blocked: Bool) { onchainSendBlocked = blocked } @@ -1227,7 +1262,7 @@ private actor AllowancePayerMock: PaykitAllowancePaying { called("markOnchainPaymentStarted") } - func payLightning(bolt11: String, sats: UInt64?) async throws { + func payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws { called("payLightning") lightningPayments.append(LightningPayment(bolt11: bolt11, sats: sats)) if let lightningGate { @@ -1255,8 +1290,9 @@ private actor AllowancePayerMock: PaykitAllowancePaying { return onchainProofSaved } - func failLightningPayment(paymentHash: String) async { + func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { called("failLightningPayment") + return lightningErrorIsDefinite } func cancelProofPreparation(_ request: PaykitPaymentRequest) async { From 141703dcd1069fbe9d96cd28a71f9a083757b0ad Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 20:20:55 +0200 Subject: [PATCH 26/46] fix: never pay an allowance without both amount limits As the payer, refuse to accept terms without a per-payment maximum and an anchored monthly limit, fail the capacity check closed when either is missing, and leave a request with an unknown creation time manual. --- Bitkit/Services/PaykitAllowance.swift | 10 ++++++++-- Bitkit/Services/PaykitAllowanceManager.swift | 9 ++++++--- BitkitTests/PaykitAllowanceTests.swift | 9 ++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index 05b4b8eb9..856001e54 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -122,6 +122,11 @@ struct PaykitAllowance: Identifiable, Hashable { } } + /// A per-payment maximum and an anchored monthly amount, the ceilings Bitkit shows and checks before paying. + var hasAmountCeiling: Bool { + perPaymentMaxSats != nil && monthlyLimitSats != nil && monthlyAnchor != nil + } + var isAnswerable: Bool { lifecycleState == .proposed && !isProposedByMe } @@ -265,8 +270,9 @@ enum PaykitAllowanceCapacity { if let perPaymentMaxSats = allowance.perPaymentMaxSats, amountSats > perPaymentMaxSats { return false } - guard let monthlyLimitSats = allowance.monthlyLimitSats, let anchor = allowance.monthlyAnchor else { - return true + // Terms without both ceilings never pay automatically, whatever the SDK accepts. + guard allowance.perPaymentMaxSats != nil, let monthlyLimitSats = allowance.monthlyLimitSats, let anchor = allowance.monthlyAnchor else { + return false } let used = usedSats(allowanceId: allowance.allowanceId, attempts: attempts, anchor: anchor, now: now) return used + amountSats <= monthlyLimitSats diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index aebe16072..cd5645506 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -150,7 +150,7 @@ final class PaykitAllowanceManager { guard allowance.isAllower, allowance.status(at: now()) == .active, PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty) else { return false } - guard let acceptedAt = allowance.lastEventAt, let createdAt = request.createdAt else { return true } + guard let acceptedAt = allowance.lastEventAt, let createdAt = request.createdAt else { return false } return createdAt >= acceptedAt.addingTimeInterval(-Self.acceptanceClockTolerance) } } @@ -198,7 +198,10 @@ final class PaykitAllowanceManager { func accept(_ entry: PaykitAllowanceEntry) async throws { try await respond(to: entry) { allowance in - guard let proposalStreamItemId = allowance.proposalStreamItemId else { throw PaykitAllowanceError.unavailable } + // As the payer, only accept terms with both ceilings this wallet shows and enforces. + guard let proposalStreamItemId = allowance.proposalStreamItemId, + allowance.role == .allowee || allowance.hasAmountCeiling + else { throw PaykitAllowanceError.unavailable } return try await self.sdk.acceptAllowance( counterparty: allowance.counterparty, allowanceId: allowance.allowanceId, @@ -280,7 +283,7 @@ final class PaykitAllowanceManager { 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: + case .started: handledAny = true case .manual, .notCovered: manualRequestIds[request.id] = signature diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index dd844e333..d219c91ed 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -246,11 +246,10 @@ final class PaykitAllowanceTests: XCTestCase { 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 testCapacityWithoutBothCeilingsNeverFits() { + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(monthlyLimitSats: nil), attempts: [], now: Fixtures.now)) + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(perPaymentMaxSats: nil), attempts: [], now: Fixtures.now)) + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(monthlyAnchor: nil), attempts: [], now: Fixtures.now)) } func testAttemptsFromHistoryKeepAutomaticAllowanceAttempts() throws { From 10abf2e76bf3d4dba28c8650cb5a130ad463cf40 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 20:20:56 +0200 Subject: [PATCH 27/46] fix: pay allowances automatically over lightning only An on-chain miner fee falls outside the allowance's limits, so a request the payee can take only on-chain stays manual and the automatic on-chain path is removed. A manual on-chain allowance payment is marked sending before its broadcast, so recovery after a kill reads it as unknown, not failed. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 150 ++------------ .../Wallets/Send/SendConfirmationView.swift | 3 + .../PaykitAllowanceExecutorTests.swift | 191 ++++-------------- journeys/allowances/README.md | 7 +- 4 files changed, 60 insertions(+), 291 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 902301a66..9bc8ae8ac 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -133,13 +133,7 @@ protocol PaykitAllowancePaying: Sendable { 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(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws - /// Whether an earlier on-chain send is unresolved, so another one cannot start. - func isOnchainSendBlocked() async -> Bool - /// Throws `OnchainSendAttemptError.preDispatch` only when nothing was broadcast, after rolling back the started proof. - func payOnchain(_ request: PaykitPaymentRequest, address: String, sats: UInt64, paymentIdentity: String) async throws -> OnchainSendResult - func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, paymentEndpointIdentifier: String) async -> Bool /// Removes the proof only when the submission error proves nothing reached the node; returns whether it did. func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool func cancelProofPreparation(_ request: PaykitPaymentRequest) async @@ -213,10 +207,6 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { 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(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws { _ = try await LightningService.shared.send( bolt11: bolt11, @@ -225,49 +215,6 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { ) } - func isOnchainSendBlocked() async -> Bool { - (try? await OnchainSendAttemptService.shared.blockingAttempt()) != nil - } - - func payOnchain(_ request: PaykitPaymentRequest, address: String, sats: UInt64, paymentIdentity: String) async throws -> OnchainSendResult { - let feeRate = await max((try? CoreService.shared.blocktank.fees(refresh: false))?.mid ?? 2, 1) - do { - return try await OnchainSendAttemptService.shared.send( - using: LightningService.shared, - address: address, - amountSats: sats, - satsPerVbyte: feeRate, - utxosToSpend: nil, - isMaxAmount: false, - requestId: request.id, - paymentIdentity: paymentIdentity, - followupContext: OnchainSendFollowupContext( - feeSats: 0, feeRate: feeRate, tags: [], contact: request.counterparty, - createdAt: UInt64(Date().timeIntervalSince1970) - ), - paymentDeadline: request.paymentDeadline, - onPreDispatchFailure: { _ in - guard await PaykitPaymentProofService.shared.failOnchainPayment(request, paymentIdentity: paymentIdentity) - else { throw OnchainSendAttemptError.preDispatchCleanupFailed } - } - ) - } catch let OnchainSendAttemptError.preDispatch(underlying) { - _ = await PaykitPaymentProofService.shared.failOnchainPayment(request, paymentIdentity: paymentIdentity) - throw OnchainSendAttemptError.preDispatch(underlying) - } - } - - func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, - paymentEndpointIdentifier: String) async -> Bool - { - await PaykitPaymentProofService.shared.completeOnchainPayment( - request, - txid: txid, - paymentAppId: paymentAppId, - paymentEndpointIdentifier: paymentEndpointIdentifier - ) - } - func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash, submissionError: submissionError) } @@ -290,7 +237,6 @@ enum PaykitAllowanceAutoPayResult: Equatable { case manual /// The payment was handed to the node; the outcome arrives through the payment events. case started - case completed /// The payee has not published a payment list newer than the one last paid; the next refresh tries again. case deferred } @@ -569,8 +515,9 @@ actor PaykitAllowanceExecutor { Logger.info("No payable private endpoint for an allowance payment; leaving it manual", context: "PaykitAllowance") return .manual } - if payment.lightningPaymentHash == nil, await payer.isOnchainSendBlocked() { - Logger.info("An earlier on-chain send is unresolved; leaving the request manual", context: "PaykitAllowance") + // An on-chain miner fee falls outside the allowance's limits, so only Lightning pays automatically. + guard let paymentHash = payment.lightningPaymentHash else { + Logger.info("An on-chain allowance payment stays manual", context: "PaykitAllowance") return .manual } @@ -622,7 +569,7 @@ actor PaykitAllowanceExecutor { amountSats: request.allowanceAmountSats, paymentEndpointIdentifier: endpointIdentifier, paymentHash: payment.lightningPaymentHash, - onchainAddress: payment.endpoint.methodId.onchainNetwork == nil ? nil : payment.endpoint.value, + onchainAddress: nil, transactionId: nil, stage: .prepared, createdAt: now() @@ -659,20 +606,10 @@ actor PaykitAllowanceExecutor { throw error } - if let paymentHash = payment.lightningPaymentHash { - return try await payLightning( - request, - payment: payment, - paymentHash: paymentHash, - attemptId: submitted.attemptId, - listAttemptId: listAttemptId, - identity: identity - ) - } - return try await payOnchain( + return try await payLightning( request, payment: payment, - paymentAppId: paymentAppId, + paymentHash: paymentHash, attemptId: submitted.attemptId, listAttemptId: listAttemptId, identity: identity @@ -732,75 +669,6 @@ actor PaykitAllowanceExecutor { return .started } - private func payOnchain( - _ request: PaykitPaymentRequest, - payment: PrivatePaykitAllowancePayment, - paymentAppId: String, - attemptId: String, - listAttemptId: UUID, - identity: String - ) async throws -> PaykitAllowanceAutoPayResult { - let address = payment.endpoint.value - do { - try setStageDurably(.sending, attemptId: attemptId, identity: identity) - 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 - } - - let result: OnchainSendResult - do { - result = try await payer.payOnchain(request, address: address, sats: request.allowanceAmountSats, paymentIdentity: identity) - } catch let OnchainSendAttemptError.preDispatch(underlying) { - await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) - try await record(attemptId: attemptId, outcome: .failed, identity: identity) - throw OnchainSendAttemptError.preDispatch(underlying) - } catch { - // Another live attempt for this request, or an outcome that was not saved, may have paid it. - Logger.warn("Automatic on-chain allowance payment outcome is uncertain: \(error)", context: "PaykitAllowance") - await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) - try await record(attemptId: attemptId, outcome: .unknown, identity: identity) - return .started - } - - let txid: String - switch result { - case let .accepted(acceptedTxid): - txid = acceptedTxid - case .rejected, .unknown: - // The durable send attempt keeps the request from being paid again until its outcome resolves. - Logger.warn("Automatic on-chain allowance payment outcome is uncertain: \(result)", context: "PaykitAllowance") - await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) - try await record(attemptId: attemptId, outcome: .unknown, identity: identity) - return .started - } - - // Recovery reads a journaled transaction as paid, so only an accepted broadcast is journaled. - updateLocalState(identity: identity) { state in - guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } - state.journal[index].transactionId = txid - } - await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .succeeded) - let proofSaved = await payer.completeOnchainPayment( - request, - txid: txid, - paymentAppId: paymentAppId, - paymentEndpointIdentifier: payment.endpoint.methodId.rawValue - ) - guard proofSaved else { - // The proof service retries the accepted send's proof; recovery records the payment once it settles. - Logger.warn("Automatic on-chain allowance payment proof is not saved yet", context: "PaykitAllowance") - try await record(attemptId: attemptId, outcome: .unknown, identity: identity) - return .started - } - try await record(attemptId: attemptId, outcome: .succeeded, identity: identity) - Self.eventSubject.send(.paidAutomatically(counterparty: request.counterparty, amountSats: request.allowanceAmountSats, paymentId: txid)) - return .completed - } - /// Called from the node's payment events for every outbound Lightning payment; only journaled ones are Allowance work. func lightningPaymentSettled(paymentHash: String, succeeded: Bool) async { guard let identity = activeIdentity else { return } @@ -892,6 +760,12 @@ actor PaykitAllowanceExecutor { } } + /// Called right before a manual on-chain broadcast, so recovery after a kill reads the attempt as unknown, not failed. + func manualOnchainPaymentSending(attemptId: String) { + guard let identity = activeIdentity else { return } + setStage(.sending, attemptId: attemptId, identity: identity) + } + func finishManualPayment(attemptId: String, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async { guard let identity = activeIdentity else { return } if let transactionId { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index f840376fe..5f451306a 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1316,6 +1316,9 @@ struct SendConfirmationView: View { onAuthorized: { _ in onchainPaymentStarted = true privatePaymentListOutcome = .uncertain + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.manualOnchainPaymentSending(attemptId: manualAllowanceAttemptId) + } }, send: { beforeBroadcastAttempt in try await wallet.send( diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index c9bafc364..9b55adda5 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1,7 +1,6 @@ @testable import Bitkit import Combine import Foundation -import LDKNode import Paykit import XCTest @@ -396,109 +395,18 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(listOutcomes, [.uncertain]) } - func testOnchainPaymentCompletesWhenItsCallerIsCancelledDuringTheSend() async throws { + func testOnchainAutoPaymentFeeIsNotSpentOutsideTheAllowance() 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]) - } - - func testRejectedOnchainBroadcastStaysUncertainWithoutJournalingTheTransaction() async throws { - let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) - await harness.payer.setOnchainResult(.rejected(txid: AllowanceHarness.onchainTxid, reason: "mempool conflict")) - let request = try Fixtures.paymentRequest() - - let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) - - XCTAssertEqual(result, .started) - XCTAssertFalse(harness.log.entries.contains("completeOnchainPayment")) - let outcomes = await harness.sdk.recordedOutcomes - XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .unknown)]) - let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal - XCTAssertEqual(journal.map(\.stage), [.unknown]) - XCTAssertEqual(journal.map(\.transactionId), [nil], "Recovery reads a journaled transaction as paid") - let listOutcomes = await harness.payer.paymentListOutcomes - XCTAssertEqual(listOutcomes, [.uncertain]) - } - - func testOnchainSendFailureBeforeBroadcastRecordsFailure() async throws { - let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) - await harness.payer.setOnchainError(OnchainSendAttemptError.preDispatch(AllowanceMockError.unsupported)) - let request = try Fixtures.paymentRequest() - let result = await harness.executor.autoPay(request, 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 listOutcomes = await harness.payer.paymentListOutcomes - XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) - } - - func testOnchainSendErrorAfterAdmissionStaysUncertain() async throws { - let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) - await harness.payer.setOnchainError(OnchainSendAttemptError.outcomeNotSaved) - let request = try Fixtures.paymentRequest() - - let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) - - XCTAssertEqual(result, .started) - let outcomes = await harness.sdk.recordedOutcomes - XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .unknown)]) - let listOutcomes = await harness.payer.paymentListOutcomes - XCTAssertEqual(listOutcomes, [.uncertain]) - } - - func testOnchainRequestStaysManualWhileAnEarlierSendIsUnresolved() async throws { - let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) - await harness.payer.setOnchainSendBlocked(true) - let request = try Fixtures.paymentRequest() - - let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) XCTAssertEqual(result, .manual) XCTAssertFalse(harness.log.entries.contains("claimForExecution")) - XCTAssertFalse(harness.log.entries.contains("payOnchain")) + XCTAssertFalse(harness.log.entries.contains("acceptPaymentRequestAutomatically")) let outcomes = await harness.sdk.recordedOutcomes XCTAssertEqual(outcomes, []) } - func testAcceptedOnchainPaymentWithoutSavedProofIsNotRecordedAsSucceeded() async throws { - let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) - await harness.payer.setOnchainProofSaved(false) - let request = try Fixtures.paymentRequest() - let events = AllowanceEventRecorder() - - let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) - - XCTAssertEqual(result, .started) - let outcomes = await harness.sdk.recordedOutcomes - XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .unknown)]) - let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal - XCTAssertEqual(journal.map(\.stage), [.unknown]) - XCTAssertEqual(journal.map(\.transactionId), [AllowanceHarness.onchainTxid]) - let paidEvents = events.events.filter { - $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: AllowanceHarness.onchainTxid) - } - XCTAssertEqual(paidEvents, []) - } - // MARK: Settlement and recovery func testLightningSettlementRecordsSuccessForTheJournaledAttempt() async throws { @@ -874,6 +782,44 @@ final class PaykitAllowanceExecutorTests: XCTestCase { // MARK: Helpers + @MainActor + func testReceivedRequestWithoutAmountCeilingCannotBeAcceptedOrAutoPaid() async throws { + let harness = AllowanceHarness() + // Valid for the SDK: an expiry alone satisfies "an amount ceiling or expiry". + let uncapped = try Paykit.AllowanceTerms( + asset: PaykitIssuerInterop.bitcoinAsset, + perPaymentAmount: nil, + periodLimits: [], + lifetimeAmountLimit: nil, + activeFrom: nil, + expiresAt: "2100-01-01T00:00:00Z", + allowedPaymentEndpointIdentifiers: nil + ) + await harness.sdk.setRecords([Fixtures.record(state: .proposed, terms: uncapped, proposedByMe: false)]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true }) + await manager.activate(identity: Fixtures.identityKey) + let entry = try XCTUnwrap(manager.entries.first) + XCTAssertEqual(entry.role, .allower) + + try? await manager.accept(entry) + + XCTAssertFalse(harness.log.entries.contains("acceptAllowance")) + let accepted = try XCTUnwrap(PaykitAllowance(record: Fixtures.record(terms: uncapped, proposedByMe: false))) + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5_000_000, allowance: accepted, attempts: [], now: Fixtures.now)) + } + + @MainActor + func testManagerLeavesARequestWithUnknownCreationTimeManual() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + 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(createdAt: "not-a-timestamp") + XCTAssertNil(request.createdAt) + + XCTAssertFalse(manager.coversRequest(request)) + } + private static func waitUntil(timeout: TimeInterval = 5, _ condition: () -> Bool) async { let deadline = Date().addingTimeInterval(timeout) while !condition(), Date() < deadline { @@ -951,8 +897,6 @@ private struct AllowanceHarness { static let onchainAddress = "bcrt1qallowancetestaddress" - static let onchainTxid = String(repeating: "c", count: 64) - static func onchainPayment() -> PrivatePaykitAllowancePayment { PrivatePaykitAllowancePayment( endpoint: PublicPaykitService.Endpoint( @@ -1134,11 +1078,6 @@ private actor AllowancePayerMock: PaykitAllowancePaying { private var claimError: Error? private var lightningError: Error? private var lightningGate: AllowanceGate? - private var onchainGate: AllowanceGate? - private var onchainResult: OnchainSendResult = .accepted(txid: AllowanceHarness.onchainTxid) - private var onchainError: Error? - private var onchainProofSaved = true - private var onchainSendBlocked = false private var lightningErrorIsDefinite = true private(set) var callCount = 0 private(set) var claimedRequestIds: [PaykitPaymentRequest.ID] = [] @@ -1170,31 +1109,10 @@ private actor AllowancePayerMock: PaykitAllowancePaying { lightningError = error } - func setOnchainResult(_ result: OnchainSendResult) { - onchainResult = result - } - - func setOnchainError(_ error: Error?) { - onchainError = error - } - - func setOnchainProofSaved(_ saved: Bool) { - onchainProofSaved = saved - } - func setLightningErrorIsDefinite(_ definite: Bool) { lightningErrorIsDefinite = definite } - func setOnchainSendBlocked(_ blocked: Bool) { - onchainSendBlocked = blocked - } - - func isOnchainSendBlocked() async -> Bool { - called("isOnchainSendBlocked") - return onchainSendBlocked - } - /// Holds `payLightning` until the returned gate opens, so a test can act while the send is in progress. func holdLightning() -> AllowanceGate { let gate = AllowanceGate() @@ -1202,12 +1120,6 @@ private actor AllowancePayerMock: PaykitAllowancePaying { 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 } @@ -1258,10 +1170,6 @@ private actor AllowancePayerMock: PaykitAllowancePaying { associatedPaymentHashes.append(paymentHash) } - func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String) async throws { - called("markOnchainPaymentStarted") - } - func payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws { called("payLightning") lightningPayments.append(LightningPayment(bolt11: bolt11, sats: sats)) @@ -1273,23 +1181,6 @@ private actor AllowancePayerMock: PaykitAllowancePaying { if let lightningError { throw lightningError } } - func payOnchain(_ request: PaykitPaymentRequest, address: String, sats: UInt64, paymentIdentity: String) async throws -> OnchainSendResult { - called("payOnchain") - if let onchainGate { - await onchainGate.wait() - try Task.checkCancellation() - } - if let onchainError { throw onchainError } - return onchainResult - } - - func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String, paymentAppId: String, - paymentEndpointIdentifier: String) async -> Bool - { - called("completeOnchainPayment") - return onchainProofSaved - } - func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { called("failLightningPayment") return lightningErrorIsDefinite diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 0da2afb3e..28fbe33d0 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -12,9 +12,10 @@ user requires one, while automatic payments never prompt. Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as 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. +requests). Automatic payments pay only the payee's private Lightning invoice; a request the payee +can take only on-chain asks as an ordinary Payment Request. 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 From b8404dcb71bfa0c2e1398caa05c5a6a3ce102ac9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 20:35:04 +0200 Subject: [PATCH 28/46] fix: drop allowance proposals the payer can't see limits for As the payer, terms without a per-payment and a monthly cap are dropped when parsed, as on Android, instead of showing a proposal that can't be accepted. --- Bitkit/Services/PaykitAllowance.swift | 8 +++----- Bitkit/Services/PaykitAllowanceManager.swift | 5 +---- BitkitTests/PaykitAllowanceExecutorTests.swift | 12 ++++-------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index 856001e54..a72424d07 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -52,6 +52,9 @@ struct PaykitAllowance: Identifiable, Hashable { else { return nil } let monthly = terms.periodLimits().first { Self.isMonthly($0.period()) } + // This wallet pays only under limits it can show: terms without a per-payment and a monthly cap are dropped. + let isBounded = terms.perPaymentAmount() != nil && monthly?.amountLimit() != nil + if role == .allower, !isBounded { return nil } id = ID(counterparty: record.counterparty, allowanceId: record.allowanceId) self.role = role lifecycleState = record.state @@ -122,11 +125,6 @@ struct PaykitAllowance: Identifiable, Hashable { } } - /// A per-payment maximum and an anchored monthly amount, the ceilings Bitkit shows and checks before paying. - var hasAmountCeiling: Bool { - perPaymentMaxSats != nil && monthlyLimitSats != nil && monthlyAnchor != nil - } - var isAnswerable: Bool { lifecycleState == .proposed && !isProposedByMe } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index cd5645506..e9303cf51 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -198,10 +198,7 @@ final class PaykitAllowanceManager { func accept(_ entry: PaykitAllowanceEntry) async throws { try await respond(to: entry) { allowance in - // As the payer, only accept terms with both ceilings this wallet shows and enforces. - guard let proposalStreamItemId = allowance.proposalStreamItemId, - allowance.role == .allowee || allowance.hasAmountCeiling - else { throw PaykitAllowanceError.unavailable } + guard let proposalStreamItemId = allowance.proposalStreamItemId else { throw PaykitAllowanceError.unavailable } return try await self.sdk.acceptAllowance( counterparty: allowance.counterparty, allowanceId: allowance.allowanceId, diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 9b55adda5..54122a7c5 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -783,7 +783,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { // MARK: Helpers @MainActor - func testReceivedRequestWithoutAmountCeilingCannotBeAcceptedOrAutoPaid() async throws { + func testReceivedRequestWithoutAmountCeilingIsDropped() async throws { let harness = AllowanceHarness() // Valid for the SDK: an expiry alone satisfies "an amount ceiling or expiry". let uncapped = try Paykit.AllowanceTerms( @@ -798,14 +798,10 @@ final class PaykitAllowanceExecutorTests: XCTestCase { await harness.sdk.setRecords([Fixtures.record(state: .proposed, terms: uncapped, proposedByMe: false)]) let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true }) await manager.activate(identity: Fixtures.identityKey) - let entry = try XCTUnwrap(manager.entries.first) - XCTAssertEqual(entry.role, .allower) - - try? await manager.accept(entry) - XCTAssertFalse(harness.log.entries.contains("acceptAllowance")) - let accepted = try XCTUnwrap(PaykitAllowance(record: Fixtures.record(terms: uncapped, proposedByMe: false))) - XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5_000_000, allowance: accepted, attempts: [], now: Fixtures.now)) + XCTAssertEqual(manager.entries, []) + XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: uncapped, proposedByMe: false))) + XCTAssertNotNil(PaykitAllowance(record: Fixtures.record(localRole: .allowee, terms: uncapped, proposedByMe: false))) } @MainActor From 95491a7b62577683ad6e46c28a9e64d25628e67d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 20:59:50 +0200 Subject: [PATCH 29/46] fix: cap routing fees on automatic allowance payments An automatic Lightning send may spend at most 1% of the request amount on routing fees, which fall outside the allowance's limits, and a request whose endpoints carry no payment list version asks, as on Android. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 11 +++++++++++ Bitkit/Services/PrivatePaykitService+Payments.swift | 4 ++-- BitkitTests/PaykitAllowanceTests.swift | 5 +++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 9bc8ae8ac..8a09870e2 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -207,10 +207,21 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { try await PaykitPaymentProofService.shared.associateLightningPayment(request, paymentHash: paymentHash) } + /// Routing fees fall outside the allowance's limits, so an automatic payment may spend at most 1% of its amount on them. + static func routeParameters(amountSats: UInt64) -> RouteParametersConfig { + RouteParametersConfig( + maxTotalRoutingFeeMsat: amountSats * 10, + maxTotalCltvExpiryDelta: 1008, + maxPathCount: 10, + maxChannelSaturationPowerOfHalf: 2 + ) + } + func payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws { _ = try await LightningService.shared.send( bolt11: bolt11, sats: sats, + params: Self.routeParameters(amountSats: request.allowanceAmountSats), beforeSubmission: { try PaykitPaymentRequest.checkPaymentDeadline(request.paymentDeadline) } ) } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index b09569310..759e43bfa 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -534,8 +534,8 @@ extension PrivatePaykitService { schedulePrivatePaymentRecovery(for: publicKey) throw PaykitAllowanceError.paymentListPending } - // Request-bound endpoints carry no payment list version, so there is no list to consume for them. - let paymentListVersion = prepared.resolution.privatePaymentListVersion + // Without a payment list version nothing limits how often the payee's endpoints can be paid, so the request asks. + guard let paymentListVersion = prepared.resolution.privatePaymentListVersion else { return nil } let eligible = Set(eligibleIdentifiers).intersection(request.acceptedPaymentEndpointIdentifiers) let candidates = resolvedEndpoints(from: prepared.resolution).filter { diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index d219c91ed..8e4d8042d 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -246,6 +246,11 @@ final class PaykitAllowanceTests: XCTestCase { XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) } + func testAutomaticLightningRoutingFeeIsCappedAtOnePercent() { + XCTAssertEqual(PaykitAllowanceLivePayer.routeParameters(amountSats: 1000).maxTotalRoutingFeeMsat, 10000) + XCTAssertEqual(PaykitAllowanceLivePayer.routeParameters(amountSats: 1).maxTotalRoutingFeeMsat, 10) + } + func testCapacityWithoutBothCeilingsNeverFits() { XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(monthlyLimitSats: nil), attempts: [], now: Fixtures.now)) XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(perPaymentMaxSats: nil), attempts: [], now: Fixtures.now)) From b18cb792164490b859c30189687d9b4e048d2ffd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 19 Oct 2026 21:09:46 +0200 Subject: [PATCH 30/46] fix: drop allowance proposals whose limits don't parse to sats As the payer, terms count as bounded only when the per-payment maximum and the monthly limit parse to sats and a monthly anchor exists, so a dust maximum shown as a dash is dropped too, as on Android. --- Bitkit/Services/PaykitAllowance.swift | 14 ++++++++------ BitkitTests/PaykitAllowanceTests.swift | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index a72424d07..53435b920 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -52,16 +52,18 @@ struct PaykitAllowance: Identifiable, Hashable { else { return nil } let monthly = terms.periodLimits().first { Self.isMonthly($0.period()) } - // This wallet pays only under limits it can show: terms without a per-payment and a monthly cap are dropped. - let isBounded = terms.perPaymentAmount() != nil && monthly?.amountLimit() != nil - if role == .allower, !isBounded { return nil } + let perPaymentMaxSats = terms.perPaymentAmount().flatMap { Self.sats(fromBitcoinAmount: $0.maximum()) } + let monthlyLimitSats = monthly?.amountLimit().flatMap { Self.sats(fromBitcoinAmount: $0) } + let monthlyAnchor = monthly?.period().anchor().flatMap(PaykitAllowanceTime.parse) + // This wallet pays only under limits it can show: terms whose caps don't parse to sats with a monthly anchor are dropped. + if role == .allower, perPaymentMaxSats == nil || monthlyLimitSats == nil || monthlyAnchor == nil { return nil } id = ID(counterparty: record.counterparty, 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) + self.perPaymentMaxSats = perPaymentMaxSats + self.monthlyLimitSats = monthlyLimitSats + self.monthlyAnchor = monthlyAnchor activeFrom = terms.activeFrom().flatMap(PaykitAllowanceTime.parse) expiresAt = terms.expiresAt().flatMap(PaykitAllowanceTime.parse) allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers() diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 8e4d8042d..c439db0ef 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -246,6 +246,27 @@ final class PaykitAllowanceTests: XCTestCase { XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) } + func testAllowerTermsWithADustPerPaymentMaximumAreDropped() throws { + let terms = try Paykit.AllowanceTerms( + asset: PaykitIssuerInterop.bitcoinAsset, + perPaymentAmount: Paykit.AllowanceAmountRange(minimum: "0", maximum: "0.000000001"), + 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: nil, + expiresAt: nil, + allowedPaymentEndpointIdentifiers: [Fixtures.lightningIdentifier] + ) + + XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: terms, proposedByMe: false))) + XCTAssertNotNil(PaykitAllowance(record: Fixtures.record(localRole: .allowee, terms: terms, proposedByMe: false))) + } + func testAutomaticLightningRoutingFeeIsCappedAtOnePercent() { XCTAssertEqual(PaykitAllowanceLivePayer.routeParameters(amountSats: 1000).maxTotalRoutingFeeMsat, 10000) XCTAssertEqual(PaykitAllowanceLivePayer.routeParameters(amountSats: 1).maxTotalRoutingFeeMsat, 10) From 2e7c71df59ba16887a526dfefc352b88303b2a10 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 20 Oct 2026 06:37:58 +0200 Subject: [PATCH 31/46] chore: format allowance code and fix swift warnings Apply SwiftFormat 0.64.0 to the files this branch changes and discard unused results, as master's lint and warnings-as-errors build now require. --- Bitkit/Services/PaykitAllowance.swift | 39 +++++++++++---- Bitkit/Services/PaykitAllowanceExecutor.swift | 15 ++++-- Bitkit/Services/PaykitAllowanceManager.swift | 47 +++++++++++++------ .../Views/Security/PaymentAuthorization.swift | 12 ++++- .../Views/Subscriptions/AllowancesView.swift | 35 ++++++++++---- .../PaykitAllowanceExecutorTests.swift | 26 +++++++--- BitkitTests/PaykitAllowanceTests.swift | 21 +++++++-- 7 files changed, 147 insertions(+), 48 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index 53435b920..57f722d9f 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -41,8 +41,13 @@ struct PaykitAllowance: Identifiable, Hashable { /// The proposal's stream item as displayed; acceptance names it so a re-sent proposal asks again. let proposalStreamItemId: UInt64? - var counterparty: String { id.counterparty } - var allowanceId: String { id.allowanceId } + var counterparty: String { + id.counterparty + } + + var allowanceId: String { + id.allowanceId + } init?(record: Paykit.AllowanceRecord) { guard let localRole = record.localRole, @@ -56,7 +61,9 @@ struct PaykitAllowance: Identifiable, Hashable { let monthlyLimitSats = monthly?.amountLimit().flatMap { Self.sats(fromBitcoinAmount: $0) } let monthlyAnchor = monthly?.period().anchor().flatMap(PaykitAllowanceTime.parse) // This wallet pays only under limits it can show: terms whose caps don't parse to sats with a monthly anchor are dropped. - if role == .allower, perPaymentMaxSats == nil || monthlyLimitSats == nil || monthlyAnchor == nil { return nil } + if role == .allower, perPaymentMaxSats == nil || monthlyLimitSats == nil || monthlyAnchor == nil { + return nil + } id = ID(counterparty: record.counterparty, allowanceId: record.allowanceId) self.role = role lifecycleState = record.state @@ -104,8 +111,12 @@ struct PaykitAllowance: Identifiable, Hashable { case .proposed: return isProposedByMe ? .awaitingAnswer : .awaitingMyAnswer case .accepted: - if let expiresAt, now >= expiresAt { return .expired } - if let activeFrom, now < activeFrom { return .notYetActive } + if let expiresAt, now >= expiresAt { + return .expired + } + if let activeFrom, now < activeFrom { + return .notYetActive + } return .active case .rejected: return .declined @@ -117,7 +128,9 @@ struct PaykitAllowance: Identifiable, Hashable { } /// The payer side: this wallet pays the counterparty's requests automatically. - var isAllower: Bool { role == .allower } + var isAllower: Bool { + role == .allower + } var canEnd: Bool { switch lifecycleState { @@ -216,7 +229,9 @@ enum PaykitAllowanceTime { static func parse(_ value: String) -> Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: value) { return date } + if let date = formatter.date(from: value) { + return date + } formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: value) } @@ -284,7 +299,7 @@ enum PaykitAllowanceCapacity { let allowanceId = attempt.allowanceId, let admittedAt = PaykitAllowanceTime.parse(attempt.admittedAt), let amountSats = PaykitAllowance.sats(fromBitcoinAmount: attempt.amount.value()) - else { return nil } + else { return nil } return Attempt( allowanceId: allowanceId, amountSats: amountSats, @@ -297,8 +312,12 @@ enum PaykitAllowanceCapacity { extension PaykitPaymentRequest { /// Allowances cover only requests priced in bitcoin without a conversion; any other request always asks. - var isAllowancePayable: Bool { amount.asset == .btc && pricing.conversion == nil } + var isAllowancePayable: Bool { + amount.asset == .btc && pricing.conversion == nil + } /// The requested amount in sats, read only for a request where `isAllowancePayable` holds. - var allowanceAmountSats: UInt64 { amount.atomic } + var allowanceAmountSats: UInt64 { + amount.atomic + } } diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 8a09870e2..aa434ac2c 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -31,7 +31,8 @@ protocol PaykitAllowanceSdkHandling: Sendable { expectedAssociationRevision: UInt64, checks: Paykit.PaymentExecutionChecks ) async throws -> Paykit.PaymentAttemptDecision - func reserveManualPayment(occurrence: Paykit.PaymentOccurrence, 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 @@ -554,7 +555,7 @@ actor PaykitAllowanceExecutor { } throw error } - try? await sdk.processOutboundPrivateMessages(counterparty: request.counterparty) + _ = try? await sdk.processOutboundPrivateMessages(counterparty: request.counterparty) let occurrence = Paykit.PaymentOccurrence(request: scope, billingPeriod: nil) let reservation = try await sdk.reserveAutomaticPayment( @@ -566,7 +567,7 @@ actor PaykitAllowanceExecutor { if case let .blocked(reason) = reservation { Logger.info("Allowance reservation blocked: \(reason)", context: "PaykitAllowance") } - try? await sdk.markPaymentManualOnly(occurrence: occurrence) + _ = try? await sdk.markPaymentManualOnly(occurrence: occurrence) notifyLimitReached(request, identity: identity) return .manual } @@ -589,7 +590,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) + _ = 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)) @@ -692,7 +693,11 @@ actor PaykitAllowanceExecutor { try await record(attemptId: entry.attemptId, outcome: succeeded ? .succeeded : .failed, identity: identity) if succeeded, entry.isAutomatic { Self.eventSubject.send( - .paidAutomatically(counterparty: entry.requestId.counterparty, amountSats: entry.amountSats, paymentId: paymentHash.lowercased()) + .paidAutomatically( + counterparty: entry.requestId.counterparty, + amountSats: entry.amountSats, + paymentId: paymentHash.lowercased() + ) ) } } catch { diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index e9303cf51..1ef7ab21d 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -10,13 +10,29 @@ struct PaykitAllowanceEntry: Identifiable, Hashable { let allowances: [PaykitAllowance] let limits: PaykitAllowanceLimits? - var primary: PaykitAllowance { allowances[0] } + var primary: PaykitAllowance { + allowances[0] + } + + var counterparty: String { + primary.counterparty + } + + var role: PaykitAllowance.Role { + primary.role + } + + var perPaymentMaxSats: UInt64? { + primary.perPaymentMaxSats + } + + var monthlyLimitSats: UInt64? { + primary.monthlyLimitSats + } - 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) } + var canEnd: Bool { + allowances.contains(where: \.canEnd) + } func status(at now: Date) -> PaykitAllowance.Status { primary.status(at: now) @@ -78,7 +94,9 @@ final class PaykitAllowanceManager { var order: [String] = [] for allowance in allowances { let key = localState.group(containing: allowance.allowanceId)?.id ?? allowance.allowanceId - if grouped[key] == nil { order.append(key) } + if grouped[key] == nil { + order.append(key) + } grouped[key, default: []].append(allowance) } return order.compactMap { key in @@ -148,9 +166,8 @@ final class PaykitAllowanceManager { guard request.isAllowancePayable else { return false } return allowances.contains { allowance in guard allowance.isAllower, allowance.status(at: now()) == .active, - PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty) - else { return false } - guard let acceptedAt = allowance.lastEventAt, let createdAt = request.createdAt else { return false } + PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty), let acceptedAt = allowance.lastEventAt, + let createdAt = request.createdAt else { return false } return createdAt >= acceptedAt.addingTimeInterval(-Self.acceptanceClockTolerance) } } @@ -182,7 +199,7 @@ final class PaykitAllowanceManager { allowedPaymentEndpointIdentifiers: Self.allowedPaymentEndpointIdentifiers ) let record = try await sdk.proposeAllowance(counterparty: contact.publicKey, localRole: .allower, terms: terms) - try? await sdk.processOutboundPrivateMessages(counterparty: contact.publicKey) + _ = try? await sdk.processOutboundPrivateMessages(counterparty: contact.publicKey) let group = PaykitAllowanceLocalState.Group( id: UUID().uuidString.lowercased(), @@ -220,7 +237,7 @@ final class PaykitAllowanceManager { for allowance in entry.allowances where allowance.canEnd { _ = try await sdk.endAllowance(counterparty: allowance.counterparty, allowanceId: allowance.allowanceId) endedAny = true - try? await sdk.processOutboundPrivateMessages(counterparty: allowance.counterparty) + _ = try? await sdk.processOutboundPrivateMessages(counterparty: allowance.counterparty) } guard endedAny else { throw PaykitAllowanceError.unavailable } await refresh() @@ -233,7 +250,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) + _ = try? await sdk.processOutboundPrivateMessages(counterparty: allowance.counterparty) } guard respondedAny else { throw PaykitAllowanceError.unavailable } if let identity { @@ -297,7 +314,9 @@ final class PaykitAllowanceManager { /// 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 isAwaitingAutomaticPayment(request) { return true } + if isAwaitingAutomaticPayment(request) { + return true + } return await executor.isHandling(request.id) } diff --git a/Bitkit/Views/Security/PaymentAuthorization.swift b/Bitkit/Views/Security/PaymentAuthorization.swift index 54b01549b..811a5619d 100644 --- a/Bitkit/Views/Security/PaymentAuthorization.swift +++ b/Bitkit/Views/Security/PaymentAuthorization.swift @@ -45,7 +45,11 @@ private struct PaymentAuthorizationModifier: ViewModifier { content .sheet(isPresented: Binding( get: { authorization.isAskingForPin }, - set: { if !$0 { authorization.resolvePin(false) } } + set: { + if !$0 { + authorization.resolvePin(false) + } + } )) { SendPinScreen( onCancel: { authorization.resolvePin(false) }, @@ -56,7 +60,11 @@ private struct PaymentAuthorizationModifier: ViewModifier { t("security__bio_error_title"), isPresented: Binding( get: { authorization.biometricErrorMessage != nil }, - set: { if !$0 { authorization.dismissBiometricError() } } + set: { + if !$0 { + authorization.dismissBiometricError() + } + } ) ) { Button(t("common__ok")) {} diff --git a/Bitkit/Views/Subscriptions/AllowancesView.swift b/Bitkit/Views/Subscriptions/AllowancesView.swift index 5cba7c26e..c46df7ae4 100644 --- a/Bitkit/Views/Subscriptions/AllowancesView.swift +++ b/Bitkit/Views/Subscriptions/AllowancesView.swift @@ -281,8 +281,14 @@ 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.limitValue(sats: $0, currency: currency) } ?? "—"])) - .accessibilityIdentifier(identifier) + 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) } @@ -382,8 +388,13 @@ struct SetAllowanceView: View { @State private var perPaymentIndex = 1 @State private var monthlyIndex = 2 - private var perPaymentUsd: Decimal { PaykitAllowanceLimits.perPaymentStopsUsd[perPaymentIndex] } - private var monthlyUsd: Decimal { PaykitAllowanceLimits.monthlyStopsUsd[monthlyIndex] } + private var perPaymentUsd: Decimal { + PaykitAllowanceLimits.perPaymentStopsUsd[perPaymentIndex] + } + + private var monthlyUsd: Decimal { + PaykitAllowanceLimits.monthlyStopsUsd[monthlyIndex] + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -565,14 +576,22 @@ struct AllowanceStepSlider: View { 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 } + 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 } + if index == 0 { + return .leading + } + if index == stops.count - 1 { + return .trailing + } return .center } } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 54122a7c5..8fdba8f7b 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -615,7 +615,11 @@ final class PaykitAllowanceExecutorTests: XCTestCase { 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") + 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")) @@ -971,7 +975,9 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen func save(_ state: PaykitAllowanceLocalState, identity: String) throws { try lock.withLock { - if savesFail { throw AllowanceMockError.unsupported } + if savesFail { + throw AllowanceMockError.unsupported + } states[identity] = state } } @@ -1118,7 +1124,9 @@ private actor AllowancePayerMock: PaykitAllowancePaying { func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? { called("resolve") - if let resolveError { throw resolveError } + if let resolveError { + throw resolveError + } return payment } @@ -1138,7 +1146,9 @@ private actor AllowancePayerMock: PaykitAllowancePaying { func claimForExecution(_ request: PaykitPaymentRequest) async throws { called("claimForExecution") - if let claimError { throw claimError } + if let claimError { + throw claimError + } claimedRequestIds.append(request.id) } @@ -1174,7 +1184,9 @@ private actor AllowancePayerMock: PaykitAllowancePaying { // 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 } + if let lightningError { + throw lightningError + } } func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { @@ -1333,7 +1345,9 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { checks: Paykit.PaymentExecutionChecks ) async throws -> Paykit.AllowanceAssociationRecord { log.append("acceptPaymentRequestAutomatically") - if let acceptError { throw acceptError } + if let acceptError { + throw acceptError + } selections.append(selection) acceptedEndpointIdentifiers.append(checks.paymentEndpointIdentifier) return Paykit.AllowanceAssociationRecord( diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index c439db0ef..86d8430ae 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -273,9 +273,24 @@ final class PaykitAllowanceTests: XCTestCase { } func testCapacityWithoutBothCeilingsNeverFits() { - XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(monthlyLimitSats: nil), attempts: [], now: Fixtures.now)) - XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(perPaymentMaxSats: nil), attempts: [], now: Fixtures.now)) - XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(monthlyAnchor: nil), attempts: [], now: Fixtures.now)) + XCTAssertFalse(PaykitAllowanceCapacity.fits( + amountSats: 5000, + allowance: Fixtures.allowance(monthlyLimitSats: nil), + attempts: [], + now: Fixtures.now + )) + XCTAssertFalse(PaykitAllowanceCapacity.fits( + amountSats: 5000, + allowance: Fixtures.allowance(perPaymentMaxSats: nil), + attempts: [], + now: Fixtures.now + )) + XCTAssertFalse(PaykitAllowanceCapacity.fits( + amountSats: 5000, + allowance: Fixtures.allowance(monthlyAnchor: nil), + attempts: [], + now: Fixtures.now + )) } func testAttemptsFromHistoryKeepAutomaticAllowanceAttempts() throws { From ea880c0f0ef78487bb68263b1379c342e6b71a94 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 20 Oct 2026 11:34:47 +0200 Subject: [PATCH 32/46] fix: answer only the shown contact's allowance proposal Allowance ids are unique only within one link, so entries never merge allowances of different contacts: a group also matches its counterparty and an ungrouped allowance is keyed by counterparty and id. Accepting one contact's offer no longer accepts another contact's ask with the same id. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 5 +-- Bitkit/Services/PaykitAllowanceManager.swift | 8 ++++- .../PaykitAllowanceExecutorTests.swift | 34 +++++++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index aa434ac2c..f7ba2e571 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -87,8 +87,9 @@ struct PaykitAllowanceLocalState: Codable, Equatable { var notifiedRequestIds: Set = [] var lastTrustedTime: Date? - func group(containing allowanceId: String) -> Group? { - groups.first { $0.allowanceIds.contains(allowanceId) } + /// Allowance ids are unique only within one link, so a group also matches its counterparty. + func group(containing allowanceId: String, counterparty: String) -> Group? { + groups.first { $0.allowanceIds.contains(allowanceId) && PubkyPublicKeyFormat.matches($0.counterparty, counterparty) } } } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 1ef7ab21d..830536991 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -10,6 +10,11 @@ struct PaykitAllowanceEntry: Identifiable, Hashable { let allowances: [PaykitAllowance] let limits: PaykitAllowanceLimits? + /// Another contact can reuse an allowance id, so an allowance outside a group is keyed by its counterparty too. + static func ungroupedId(for allowance: PaykitAllowance) -> String { + "\(allowance.counterparty)/\(allowance.allowanceId)" + } + var primary: PaykitAllowance { allowances[0] } @@ -93,7 +98,8 @@ final class PaykitAllowanceManager { var grouped: [String: [PaykitAllowance]] = [:] var order: [String] = [] for allowance in allowances { - let key = localState.group(containing: allowance.allowanceId)?.id ?? allowance.allowanceId + let key = localState.group(containing: allowance.allowanceId, counterparty: allowance.counterparty)?.id + ?? PaykitAllowanceEntry.ungroupedId(for: allowance) if grouped[key] == nil { order.append(key) } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 8fdba8f7b..83e2fb600 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -627,6 +627,31 @@ final class PaykitAllowanceExecutorTests: XCTestCase { // MARK: Manager + @MainActor + func testAcceptingAProposalNeverAnswersAnotherContactsProposalWithTheSameId() async throws { + let harness = AllowanceHarness() + let sharedId = Fixtures.otherAllowanceId + let offer = try Fixtures.record( + allowanceId: sharedId, localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), + proposedByMe: false, lastEventAt: "2026-09-02T10:00:00Z" + ) + let ask = try Fixtures.record( + allowanceId: sharedId, counterparty: Fixtures.otherCounterpartyKey, localRole: .allower, state: .proposed, + terms: Fixtures.standardTerms(), proposedByMe: false, lastEventAt: "2026-09-01T10:00:00Z" + ) + await harness.sdk.setRecords([offer, ask]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + let presented = try XCTUnwrap(manager.proposalForPresentation()) + XCTAssertEqual(presented.role, .allowee) + XCTAssertEqual(presented.counterparty, Fixtures.counterpartyKey) + try await manager.accept(presented) + + let accepted = await harness.sdk.acceptedCounterparties + XCTAssertEqual(accepted, [Fixtures.counterpartyKey], "Accepting C's offer must not accept M's ask as payer") + } + @MainActor func testManagerPresentsEachGrantAsOneEntryWithItsLimitsAndLeavesInvalidHistoryOut() async throws { let harness = AllowanceHarness() @@ -649,7 +674,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { await manager.activate(identity: Fixtures.identityKey) let entries = manager.entries - XCTAssertEqual(entries.map(\.id), ["group-1", Fixtures.otherAllowanceId]) + XCTAssertEqual(entries.map(\.id), ["group-1", "\(Fixtures.otherCounterpartyKey)/\(Fixtures.otherAllowanceId)"]) let grant = try XCTUnwrap(entries.first) XCTAssertEqual(grant.allowances.map(\.allowanceId), [Fixtures.walletAllowanceId]) XCTAssertEqual(grant.limits, Fixtures.limits) @@ -1226,6 +1251,7 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { private(set) var reconciliations: [Paykit.AllowanceAccountingReconciliation] = [] private(set) var manualOnlyOccurrences: [Paykit.PaymentOccurrence] = [] private(set) var proposals: [Proposal] = [] + private(set) var acceptedCounterparties: [String] = [] init(log: AllowanceCallLog) { self.log = log @@ -1292,7 +1318,11 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { func acceptAllowance(counterparty: String, allowanceId: String, expectedProposalStreamItemId: UInt64) async throws -> Paykit.AllowanceRecord { log.append("acceptAllowance") - throw AllowanceMockError.unsupported + acceptedCounterparties.append(counterparty) + guard let record = records.first(where: { $0.counterparty == counterparty && $0.allowanceId == allowanceId }) else { + throw AllowanceMockError.unsupported + } + return record } func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { From 407c2255cd34847fa0c609f0c616e119f2ed5561 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 20 Oct 2026 11:59:16 +0200 Subject: [PATCH 33/46] fix: show each contact's allowance proposal even when ids match Presented proposals are tracked by counterparty and allowance id, as on Android, so showing one contact's offer no longer hides another contact's ask that reuses its id. --- Bitkit/Services/PaykitAllowance.swift | 5 ++++ Bitkit/Services/PaykitAllowanceManager.swift | 13 ++++------- .../PaykitAllowanceExecutorTests.swift | 23 +++++++++++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index 57f722d9f..50abf4f72 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -49,6 +49,11 @@ struct PaykitAllowance: Identifiable, Hashable { id.allowanceId } + /// Allowance ids are unique only within one link, so local state keys an allowance by its counterparty too. + var scopedId: String { + "\(counterparty)/\(allowanceId)" + } + init?(record: Paykit.AllowanceRecord) { guard let localRole = record.localRole, let role = Role(localRole), diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 830536991..5c57587e2 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -10,11 +10,6 @@ struct PaykitAllowanceEntry: Identifiable, Hashable { let allowances: [PaykitAllowance] let limits: PaykitAllowanceLimits? - /// Another contact can reuse an allowance id, so an allowance outside a group is keyed by its counterparty too. - static func ungroupedId(for allowance: PaykitAllowance) -> String { - "\(allowance.counterparty)/\(allowance.allowanceId)" - } - var primary: PaykitAllowance { allowances[0] } @@ -99,7 +94,7 @@ final class PaykitAllowanceManager { var order: [String] = [] for allowance in allowances { let key = localState.group(containing: allowance.allowanceId, counterparty: allowance.counterparty)?.id - ?? PaykitAllowanceEntry.ungroupedId(for: allowance) + ?? allowance.scopedId if grouped[key] == nil { order.append(key) } @@ -260,7 +255,7 @@ final class PaykitAllowanceManager { } guard respondedAny else { throw PaykitAllowanceError.unavailable } if let identity { - let ids = entry.allowances.map(\.allowanceId) + let ids = entry.allowances.map(\.scopedId) await executor.updateLocalState(identity: identity) { $0.presentedProposalIds.formUnion(ids) } } await refresh() @@ -271,13 +266,13 @@ final class PaykitAllowanceManager { func proposalForPresentation() -> PaykitAllowanceEntry? { entries.first { entry in entry.allowances.contains(where: \.isAnswerable) && - !entry.allowances.contains { localState.presentedProposalIds.contains($0.allowanceId) } + !entry.allowances.contains { localState.presentedProposalIds.contains($0.scopedId) } } } func markProposalPresented(_ entry: PaykitAllowanceEntry) async { guard let identity else { return } - let ids = entry.allowances.map(\.allowanceId) + let ids = entry.allowances.map(\.scopedId) await executor.updateLocalState(identity: identity) { $0.presentedProposalIds.formUnion(ids) } localState.presentedProposalIds.formUnion(ids) } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 83e2fb600..5d706d69a 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -652,6 +652,29 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(accepted, [Fixtures.counterpartyKey], "Accepting C's offer must not accept M's ask as payer") } + @MainActor + func testPresentingOneContactsProposalKeepsAnotherContactsProposalWithTheSameIdPresentable() async throws { + let harness = AllowanceHarness() + let sharedId = Fixtures.otherAllowanceId + let offer = try Fixtures.record( + allowanceId: sharedId, localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), + proposedByMe: false, lastEventAt: "2026-09-02T10:00:00Z" + ) + let ask = try Fixtures.record( + allowanceId: sharedId, counterparty: Fixtures.otherCounterpartyKey, localRole: .allower, state: .proposed, + terms: Fixtures.standardTerms(), proposedByMe: false, lastEventAt: "2026-09-01T10:00:00Z" + ) + await harness.sdk.setRecords([offer, ask]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + let first = try XCTUnwrap(manager.proposalForPresentation()) + await manager.markProposalPresented(first) + + let second = try XCTUnwrap(manager.proposalForPresentation()) + XCTAssertNotEqual(second.counterparty, first.counterparty) + } + @MainActor func testManagerPresentsEachGrantAsOneEntryWithItsLimitsAndLeavesInvalidHistoryOut() async throws { let harness = AllowanceHarness() From a844c40cb334c169cd971b552cab7c77955ed86a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 20 Oct 2026 14:48:47 +0200 Subject: [PATCH 34/46] docs: name the allowance row by its entry id in journeys Since entries are keyed by contact and id, the row identifier is the entry id, as on Android. --- journeys/allowances/README.md | 2 +- journeys/allowances/set-and-accept.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 28fbe33d0..6f79efde2 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -42,7 +42,7 @@ killed right after handing the payment to the node never paid twice after relaun - Set sheet: `SetAllowance`, sliders `AllowancePerPayment` and `AllowanceMonthly` with stops `AllowancePerPaymentStop-` and `AllowanceMonthlyStop-`, `AllowanceSummary`, `AllowanceSave` -- List row: `AllowanceRow-` with its status line `AllowanceRowStatus` +- List row: `AllowanceRow-` with its status line `AllowanceRowStatus`; the entry id is the payer's grant id, or the contact key and allowance id on the payee - Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline` - Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus` diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml index 83f2bd35d..e6b7e9c68 100644 --- a/journeys/allowances/set-and-accept.xml +++ b/journeys/allowances/set-and-accept.xml @@ -16,7 +16,7 @@ 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 + Verify the sheet dismisses and the list shows one row for the payee (id "AllowanceRow-<entryId>") 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") From b059b8e8a17793e2933913c0d65a4e5da4a00167 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 20 Oct 2026 15:15:03 +0200 Subject: [PATCH 35/46] fix: count each contact's automatic payments only toward its own allowance Allowance ids are unique only within one link, so the selected allowance matches the request's contact, capacity attempts carry their counterparty, and auto-paid totals are keyed by contact and id. Another contact's payments under the same id no longer fill this contact's cap or its paid-so-far total. --- Bitkit/Services/PaykitAllowance.swift | 22 ++++++++++++++---- Bitkit/Services/PaykitAllowanceExecutor.swift | 4 +++- Bitkit/Services/PaykitAllowanceManager.swift | 13 ++++++----- BitkitTests/PaykitAllowanceTests.swift | 23 +++++++++++++++++-- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index 50abf4f72..09702f8b1 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -51,7 +51,11 @@ struct PaykitAllowance: Identifiable, Hashable { /// Allowance ids are unique only within one link, so local state keys an allowance by its counterparty too. var scopedId: String { - "\(counterparty)/\(allowanceId)" + Self.scopedId(counterparty: counterparty, allowanceId: allowanceId) + } + + static func scopedId(counterparty: String, allowanceId: String) -> String { + "\(PubkyPublicKeyFormat.normalized(counterparty) ?? counterparty)/\(allowanceId)" } init?(record: Paykit.AllowanceRecord) { @@ -273,16 +277,21 @@ enum PaykitAllowanceTime { /// 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 counterparty: String let allowanceId: String let amountSats: UInt64 let admittedAt: Date let isLive: Bool } - static func usedSats(allowanceId: String, attempts: [Attempt], anchor: Date, now: Date) -> UInt64 { + /// Allowance ids are unique only within one link, so attempts count toward an allowance only under its counterparty. + static func usedSats(counterparty: String, 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 } + .filter { + $0.allowanceId == allowanceId && PubkyPublicKeyFormat.matches($0.counterparty, counterparty) && $0.isLive && + $0.admittedAt >= window.start && $0.admittedAt < window.end + } .reduce(0) { $0 + $1.amountSats } } @@ -294,18 +303,21 @@ enum PaykitAllowanceCapacity { guard allowance.perPaymentMaxSats != nil, let monthlyLimitSats = allowance.monthlyLimitSats, let anchor = allowance.monthlyAnchor else { return false } - let used = usedSats(allowanceId: allowance.allowanceId, attempts: attempts, anchor: anchor, now: now) + let used = usedSats(counterparty: allowance.counterparty, 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 + history.occurrences.flatMap { occurrence in + occurrence.attempts.map { (counterparty: occurrence.key.request.counterparty, attempt: $0) } + }.compactMap { counterparty, 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( + counterparty: counterparty, allowanceId: allowanceId, amountSats: amountSats, admittedAt: admittedAt, diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index f7ba2e571..0cd354e41 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -496,7 +496,9 @@ actor PaykitAllowanceExecutor { 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 }) + let allowance = allowances.first(where: { + $0.allowanceId == candidate.allowanceId && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) + }) else { let reasons = candidates.compactMap { $0.blocked.map { String(describing: $0) } } Logger.info("No eligible allowance for an incoming request: \(reasons)", context: "PaykitAllowance") diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 5c57587e2..502e5580e 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -59,7 +59,8 @@ final class PaykitAllowanceManager { private(set) var localState = PaykitAllowanceLocalState() private(set) var isWorking = false private(set) var autoPaidRequestIds: Set = [] - private(set) var autoPaidSatsByAllowanceId: [String: UInt64] = [:] + /// Keyed by `PaykitAllowance.scopedId`, since another contact can reuse an allowance id. + private(set) var autoPaidSatsByScopedId: [String: UInt64] = [:] @ObservationIgnored private let sdk: any PaykitAllowanceSdkHandling @ObservationIgnored private let executor: PaykitAllowanceExecutor @@ -132,7 +133,7 @@ final class PaykitAllowanceManager { allowances = [] localState = PaykitAllowanceLocalState() autoPaidRequestIds = [] - autoPaidSatsByAllowanceId = [:] + autoPaidSatsByScopedId = [:] manualRequestIds = [:] } @@ -151,14 +152,14 @@ final class PaykitAllowanceManager { 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 + autoPaidSatsByScopedId = paid.reduce(into: [:]) { totals, entry in guard let allowanceId = entry.allowanceId else { return } - totals[allowanceId, default: 0] += entry.amountSats + totals[PaykitAllowance.scopedId(counterparty: entry.requestId.counterparty, allowanceId: allowanceId), default: 0] += entry.amountSats } } func autoPaidSats(for entry: PaykitAllowanceEntry) -> UInt64 { - entry.allowances.reduce(0) { $0 + (autoPaidSatsByAllowanceId[$1.allowanceId] ?? 0) } + entry.allowances.reduce(0) { $0 + (autoPaidSatsByScopedId[$1.scopedId] ?? 0) } } /// Whether an incoming request is covered by an active Allowance this wallet granted. A request created before the @@ -179,7 +180,7 @@ final class PaykitAllowanceManager { hasher.combine(allowance.allowanceId) hasher.combine(allowance.lifecycleState.rawDescription) } - hasher.combine(autoPaidSatsByAllowanceId.values.reduce(0, +)) + hasher.combine(autoPaidSatsByScopedId.values.reduce(0, +)) return hasher.finalize() } diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 86d8430ae..a43372fb8 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -167,6 +167,7 @@ final class PaykitAllowanceTests: XCTestCase { let beforeMarchAnchor = PaykitAllowanceTime.monthlyWindow(anchor: marchAnchor, containing: Fixtures.utc("2026-01-15T00:00:00Z")) let allowance = Fixtures.allowance(monthlyAnchor: anchor) let lateMarchAttempt = PaykitAllowanceCapacity.Attempt( + counterparty: Fixtures.counterpartyKey, allowanceId: allowance.allowanceId, amountSats: 50000, admittedAt: Fixtures.utc("2026-03-29T09:00:00Z"), @@ -246,6 +247,16 @@ final class PaykitAllowanceTests: XCTestCase { XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) } + func testAnotherContactsAttemptsUnderTheSameIdNeverFillThisContactsCap() { + let attempts = [ + Fixtures.capacityAttempt(counterparty: Fixtures.otherCounterpartyKey, sats: 45000, at: "2026-09-05T10:00:00Z"), + Fixtures.capacityAttempt(sats: 1000, at: "2026-09-06T10:00:00Z"), + ] + + XCTAssertEqual(Fixtures.usedSats(attempts), 1000) + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + func testAllowerTermsWithADustPerPaymentMaximumAreDropped() throws { let terms = try Paykit.AllowanceTerms( asset: PaykitIssuerInterop.bitcoinAsset, @@ -314,18 +325,21 @@ final class PaykitAllowanceTests: XCTestCase { XCTAssertEqual(attempts, [ PaykitAllowanceCapacity.Attempt( + counterparty: Fixtures.counterpartyKey, allowanceId: Fixtures.walletAllowanceId, amountSats: 10000, admittedAt: Fixtures.utc("2026-09-02T10:00:00Z"), isLive: false ), PaykitAllowanceCapacity.Attempt( + counterparty: Fixtures.counterpartyKey, allowanceId: Fixtures.walletAllowanceId, amountSats: 10000, admittedAt: Fixtures.utc("2026-09-03T10:00:00Z"), isLive: true ), PaykitAllowanceCapacity.Attempt( + counterparty: Fixtures.counterpartyKey, allowanceId: Fixtures.walletAllowanceId, amountSats: 2000, admittedAt: Fixtures.utc("2026-09-05T10:00:00Z"), @@ -494,15 +508,20 @@ enum PaykitAllowanceFixtures { static func capacityAttempt( allowanceId: String = walletAllowanceId, + counterparty: String = counterpartyKey, sats: UInt64, at timestamp: String, isLive: Bool = true ) -> PaykitAllowanceCapacity.Attempt { - PaykitAllowanceCapacity.Attempt(allowanceId: allowanceId, amountSats: sats, admittedAt: utc(timestamp), isLive: isLive) + PaykitAllowanceCapacity.Attempt( + counterparty: counterparty, 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) + PaykitAllowanceCapacity.usedSats( + counterparty: counterpartyKey, allowanceId: walletAllowanceId, attempts: attempts, anchor: septemberAnchor, now: now + ) } static func attemptRecord( From da90209eb44221579de1f870268215a3890234c8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 16:06:35 +0200 Subject: [PATCH 36/46] fix: let requests over the allowance limits ask while a channel reconnects A covered request over every limit of its allowances is handed back for manual payment before the wait for a usable channel. A held automatic request no longer stalls other Paykit sheets, a request whose manual sheet is open is not paid automatically, a refused acceptance keeps an intent the manual flow saved, an execution startup error releases its reservation, and recovery announces the automatic payments it settles. --- Bitkit/AppScene.swift | 7 +- Bitkit/Services/PaykitAllowanceExecutor.swift | 58 ++++++++++-- Bitkit/Services/PaykitAllowanceManager.swift | 14 ++- .../PaykitPaymentRequestService.swift | 9 ++ .../PaykitAllowanceExecutorTests.swift | 93 ++++++++++++++++++- 5 files changed, 166 insertions(+), 15 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 69f60915d..b6451a53e 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1422,7 +1422,11 @@ struct AppScene: View { // Deleting the profile withdraws the payment lists an automatic payment would consume. if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile { await paykitAllowanceManager.refresh() - let handledAutomatically = await paykitAllowanceManager.processIncomingRequests(paykitPaymentRequestManager.pendingRequests) + // A request whose manual sheet is already open stays with the user. + let manualRequestId = incomingPaymentRequestPreparation.flatMap { $0.ownsSheet(sheets) ? $0.request?.id : nil } + let handledAutomatically = await paykitAllowanceManager.processIncomingRequests( + paykitPaymentRequestManager.pendingRequests.filter { $0.id != manualRequestId } + ) let adoptedAcceptances = paykitPaymentRequestManager.reloadAcceptedRequestIds() if handledAutomatically || adoptedAcceptances, canRefresh() { await paykitPaymentRequestManager.refresh(mode: .stored, canRefresh: canRefresh) @@ -1589,6 +1593,7 @@ struct AppScene: View { guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } if await paykitAllowanceManager.isAutomaticallyHandling(request) { // The next refresh presents it if the allowance flow hands it back; presenting again now would spin on it. + paykitPaymentRequestManager.releaseRequestedPresentation(request) shouldPresentNextRequest = false continue } diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 0cd354e41..037cb6b52 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -130,7 +130,9 @@ protocol PaykitAllowancePaying: Sendable { 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 + /// Returns whether this call added the intent, so a refused acceptance never drops one the manual flow saved. + @discardableResult + func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool 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 @@ -172,11 +174,13 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { try await PaykitPaymentRequestService().claimForPayment(request) } - func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws { + @discardableResult + func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool { let store = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) var ids = try store.load(identity: identity) - ids.insert(request.id) + guard ids.insert(request.id).inserted else { return false } try store.save(ids, identity: identity) + return true } func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async { @@ -383,6 +387,9 @@ actor PaykitAllowanceExecutor { continue } try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) + if outcome == .succeeded { + announceAutomaticPayment(attemptId: attempt.attemptId, identity: identity) + } case .succeeded, .failed: continue } @@ -392,6 +399,30 @@ actor PaykitAllowanceExecutor { } } + /// Recovery settles payments no settlement event reported, so it raises the same contact link and notification. + private func announceAutomaticPayment(attemptId: String, identity: String) { + guard let entry = localState(identity: identity).journal.first(where: { $0.attemptId == attemptId }), + entry.isAutomatic, let paymentHash = entry.paymentHash + else { return } + Self.eventSubject.send( + .paidAutomatically(counterparty: entry.requestId.counterparty, amountSats: entry.amountSats, paymentId: paymentHash.lowercased()) + ) + } + + /// Whether a covered request is over every limit of the contact's accepted allowances, decided without the node. + func exceedsLimits(_ request: PaykitPaymentRequest, allowances: [PaykitAllowance], identity: String) async -> Bool { + let covering = allowances.filter { + $0.isAllower && $0.lifecycleState == .accepted && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) + } + guard !covering.isEmpty else { return false } + let attempts = await automaticAttempts() + guard !covering.contains(where: { + PaykitAllowanceCapacity.fits(amountSats: request.allowanceAmountSats, allowance: $0, attempts: attempts, now: now()) + }) else { return false } + notifyLimitReached(request, identity: identity) + return true + } + private func verifiedOutcome(for attempt: Paykit.PaymentAttemptRecord, identity: String) async -> Paykit.PaymentOutcome? { switch attempt.status { case .prepared: @@ -540,7 +571,7 @@ 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) - try await payer.recordAcceptanceIntent(request, identity: identity) + let recordedAcceptanceIntent = try await payer.recordAcceptanceIntent(request, identity: identity) let association: Paykit.AllowanceAssociationRecord do { association = try await sdk.acceptPaymentRequestAutomatically( @@ -554,7 +585,9 @@ actor PaykitAllowanceExecutor { case is CancellationError, PaykitError.Transport, PaykitError.Storage, PaykitError.Identity: break default: - await payer.discardAcceptanceIntent(request, identity: identity) + if recordedAcceptanceIntent { + await payer.discardAcceptanceIntent(request, identity: identity) + } } throw error } @@ -594,10 +627,17 @@ 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) - let handoff = try await sdk.beginPaymentExecution( - attemptId: prepared.attemptId, - checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity)) - ) + let handoff: Paykit.PaymentAttemptDecision + do { + handoff = try await sdk.beginPaymentExecution( + attemptId: prepared.attemptId, + checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity)) + ) + } catch { + // Nothing left the wallet, so release the reservation before the request goes back to manual payment. + try? await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity) + throw error + } guard case let .ready(submitted) = handoff else { try await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity) return .manual diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 502e5580e..42f4205e7 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -280,16 +280,22 @@ final class PaykitAllowanceManager { // 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. + /// Pays incoming requests that an active Allowance covers. Returns whether any request was handled or handed back + /// for manual payment, so the caller refreshes before presenting the rest. func processIncomingRequests(_ requests: [PaykitPaymentRequest]) async -> Bool { guard let identity, !isProcessingRequests else { return false } let signature = allowancesSignature let covered = requests.filter(isAwaitingAutomaticPayment) guard !covered.isEmpty 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 + // A request over the limits needs the user anyway, so it is handed back before waiting for a channel. + var handedBack = false + for request in covered where await executor.exceedsLimits(request, allowances: allowances, identity: identity) { + manualRequestIds[request.id] = signature + handedBack = true + } + Logger.info("Holding covered requests until the node has a usable channel", context: "PaykitAllowance") + return handedBack } isProcessingRequests = true diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 3a25ab32d..3c4451b98 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -2194,6 +2194,15 @@ final class PaykitPaymentRequestManager { return true } + /// Ends a requested presentation the allowance flow owns, so it does not hold every other Paykit sheet back. + func releaseRequestedPresentation(_ request: PaykitPaymentRequest) { + guard requestedPresentationId == request.id else { return } + requestedPresentationId = nil + presentationRetryAttempts.removeValue(forKey: request.id) + presentationRetryDeadlines.removeValue(forKey: request.id) + presentationGeneration += 1 + } + func dismissPreparingRequest(_ request: PaykitPaymentRequest) { guard pendingRequests.contains(where: { $0.id == request.id }), requestedPresentationId == nil || requestedPresentationId == request.id, diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 5d706d69a..6bb2e0e0b 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -165,6 +165,38 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(ownsAfterManual, "A request the pass found manual goes to the Send sheet") } + @MainActor + func testManagerHandsBackARequestAboveTheLimitsWhileWaitingForAChannel() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + let manager = PaykitAllowanceManager( + sdk: harness.sdk, + executor: harness.executor, + now: { PaykitAllowanceFixtures.now }, + canPayNow: { false } + ) + await manager.activate(identity: Fixtures.identityKey) + let aboveLimit = try Fixtures.paymentRequest(amount: "0.0001") + let withinLimits = try Fixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440002") + let events = AllowanceEventRecorder() + + let handedBack = await manager.processIncomingRequests([aboveLimit, withinLimits]) + + XCTAssertTrue(handedBack) + let ownsAboveLimit = await manager.isAutomaticallyHandling(aboveLimit) + let ownsWithinLimits = await manager.isAutomaticallyHandling(withinLimits) + XCTAssertFalse(ownsAboveLimit, "A request over the limits goes to the Send sheet without waiting for a channel") + XCTAssertTrue(ownsWithinLimits, "A request within the limits waits for the channel") + XCTAssertEqual(events.events.filter { + if case .limitReached = $0 { + true + } else { + false + } + }.count, 1) + XCTAssertFalse(harness.log.entries.contains("payLightning")) + } + @MainActor func testManagerKeepsADeferredRequestOffTheSendSheet() async throws { let harness = AllowanceHarness() @@ -443,6 +475,51 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(harness.log.entries.filter { $0 == "payLightning" || $0 == "payOnchain" }, []) } + func testExecutionStartupErrorReleasesTheReservation() async throws { + let harness = AllowanceHarness() + await harness.sdk.setBeginError(AllowanceMockError.unsupported) + + 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)]) + XCTAssertFalse(harness.log.entries.contains("payLightning")) + } + + func testRefusedAcceptanceKeepsAnIntentTheManualFlowSaved() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + await harness.payer.seedAcceptanceIntent(request.id) + await harness.sdk.setAcceptError(AllowanceMockError.unsupported) + + _ = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let intents = await harness.payer.acceptanceIntents + XCTAssertEqual(intents, [request.id]) + XCTAssertFalse(harness.log.entries.contains("discardAcceptanceIntent")) + } + + func testRecoveryAnnouncesAnAutomaticPaymentItSettles() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + 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: String(repeating: "0", count: 64))]) + let events = AllowanceEventRecorder() + + await harness.executor.recover(identity: Fixtures.identityKey) + + let paid = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: paidHash) } + XCTAssertEqual(paid.count, 1) + } + func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() @@ -1200,9 +1277,15 @@ private actor AllowancePayerMock: PaykitAllowancePaying { claimedRequestIds.append(request.id) } - func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws { + func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool { called("recordAcceptanceIntent") + guard !acceptanceIntents.contains(request.id) else { return false } acceptanceIntents.append(request.id) + return true + } + + func seedAcceptanceIntent(_ id: PaykitPaymentRequest.ID) { + acceptanceIntents.append(id) } func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async { @@ -1265,6 +1348,7 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { private var automaticReservation: Paykit.PaymentAttemptDecision? private var manualReservation: Paykit.PaymentAttemptDecision? private var beginDecision: Paykit.PaymentAttemptDecision? + private var beginError: Error? private var acceptError: Error? private(set) var evaluatedTrustedTimes: [String] = [] private(set) var selections: [Paykit.AllowanceSelectionInput] = [] @@ -1312,6 +1396,10 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { acceptError = error } + func setBeginError(_ error: Error?) { + beginError = error + } + func linkedPeers() async throws -> [LinkedPeerRecord] { log.append("linkedPeers") return peers @@ -1452,6 +1540,9 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision { log.append("beginPaymentExecution") + if let beginError { + throw beginError + } if let beginDecision { return beginDecision } From 7cb14fbe2127b248b7939b2251d1acd112db91bc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 16:12:12 +0200 Subject: [PATCH 37/46] fix: tell the payer a held request is already being paid Tapping Pay on a request the allowance flow holds shows that it is already being paid, as on Android, and recovery announces only payments no settlement event announced. --- Bitkit/AppScene.swift | 5 ++++- Bitkit/Services/PaykitAllowanceExecutor.swift | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index b6451a53e..5da82f5ca 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1593,7 +1593,10 @@ struct AppScene: View { guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } if await paykitAllowanceManager.isAutomaticallyHandling(request) { // The next refresh presents it if the allowance flow hands it back; presenting again now would spin on it. - paykitPaymentRequestManager.releaseRequestedPresentation(request) + if paykitPaymentRequestManager.requestedPresentationId == request.id { + app.toast(type: .info, title: t("subscriptions__allowance_payment_in_progress")) + paykitPaymentRequestManager.releaseRequestedPresentation(request) + } shouldPresentNextRequest = false continue } diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 037cb6b52..e1daf32a4 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -386,8 +386,10 @@ actor PaykitAllowanceExecutor { } continue } + let wasSettled = localState(identity: identity).journal.first { $0.attemptId == attempt.attemptId }?.stage == .succeeded try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) - if outcome == .succeeded { + // A settlement event already announced a journal entry it settled. + if outcome == .succeeded, !wasSettled { announceAutomaticPayment(attemptId: attempt.attemptId, identity: identity) } case .succeeded, .failed: From fd4c7018714e062767db57bddc370cfeefe15c48 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 16:12:12 +0200 Subject: [PATCH 38/46] feat: translate the allowance strings into every supported language --- .../Localization/ar.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/ca.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/cs.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/de.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/el.lproj/Localizable.strings | 47 +++++++++++++++++++ .../es-419.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/es.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/fr.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/it.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/nl.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/pl.lproj/Localizable.strings | 47 +++++++++++++++++++ .../pt-BR.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/pt.lproj/Localizable.strings | 47 +++++++++++++++++++ .../Localization/ru.lproj/Localizable.strings | 47 +++++++++++++++++++ 14 files changed, 658 insertions(+) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 0bcf440cd..3c090e3fa 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1580,3 +1580,50 @@ "wallet__receive_liquidity__label_additional" = "سيولة رصيد إنفاق إضافية"; "wallet__receive_cjit_error_max__title" = "الحد الأقصى لسعة الاستلام"; "wallet__receive_cjit_error_max__description" = "الحد الأقصى الذي يمكنك استلامه في رصيد الإنفاق الآن هو ₿ {amount}."; +"subscriptions__allowances" = "المخصصات"; +"subscriptions__allowances_empty_headline" = "أعدّ\nالمخصصات"; +"subscriptions__allowances_empty_description" = "يمكنك تعيين حدود إنفاق لتلبية طلبات الدفع من جهات الاتصال الموثوقة تلقائيًا."; +"subscriptions__allowance_add" = "إضافة مخصص"; +"subscriptions__allowance_title" = "المخصص"; +"subscriptions__allowance_choose_contact" = "اختر جهة اتصال"; +"subscriptions__allowance_no_contacts" = "أضف جهة اتصال أولًا. تغطي المخصصات طلبات الدفع من جهات اتصالك."; +"subscriptions__allowance_set_title" = "تعيين المخصص"; +"subscriptions__allowance_set_explanation" = "وافق تلقائيًا على طلبات الدفع من {name} ضمن هذه الحدود:"; +"subscriptions__allowance_payment_limit" = "حد الدفعة"; +"subscriptions__allowance_monthly_allowance" = "المخصص الشهري"; +"subscriptions__allowance_set_summary" = "ستُدفع الطلبات حتى {perPayment} تلقائيًا، بحد أقصى {monthly} شهريًا، دون سؤالك في كل مرة."; +"subscriptions__allowance_save" = "حفظ المخصص"; +"subscriptions__allowance_monthly_limit" = "الحد الشهري"; +"subscriptions__allowance_per_payment_short" = "{amount} للدفعة"; +"subscriptions__allowance_per_payment" = "لكل دفعة"; +"subscriptions__allowance_each_month" = "كل شهر"; +"subscriptions__allowance_up_to" = "حتى {amount}"; +"subscriptions__allowance_status_active" = "نشط"; +"subscriptions__allowance_status_waiting" = "بانتظار الرد"; +"subscriptions__allowance_status_needs_answer" = "بانتظار ردك"; +"subscriptions__allowance_status_scheduled" = "غير نشط بعد"; +"subscriptions__allowance_status_expired" = "منتهي الصلاحية"; +"subscriptions__allowance_status_declined" = "مرفوض"; +"subscriptions__allowance_status_conflicted" = "يحتاج إلى انتباه"; +"subscriptions__allowance_status_ended" = "منتهٍ"; +"subscriptions__allowance_paid_automatically" = "تم دفع {amount} تلقائيًا"; +"subscriptions__allowance_paid_so_far" = "مدفوع تلقائيًا"; +"subscriptions__allowance_offer_headline" = "{name} يعرض\nمخصصًا"; +"subscriptions__allowance_request_headline" = "{name} يطلب\nمخصصًا"; +"subscriptions__allowance_offer_explanation" = "ستدفع محفظة {name} طلباتك ضمن هذه الحدود دون السؤال في كل مرة. يمكن لأي منكما إنهاؤه."; +"subscriptions__allowance_request_explanation" = "ستدفع محفظتك طلبات {name} ضمن هذه الحدود دون سؤالك في كل مرة. يمكن لأي منكما إنهاؤه."; +"subscriptions__allowance_accept" = "قبول"; +"subscriptions__allowance_decline" = "رفض"; +"subscriptions__allowance_swipe_end" = "اسحب لإنهاء المخصص"; +"subscriptions__allowance_swipe_withdraw" = "اسحب للتراجع"; +"subscriptions__allowance_detail_active_allower" = "تُدفع الطلبات ضمن هذه الحدود تلقائيًا. أي طلب يتجاوزها يحتاج إلى موافقتك أولًا."; +"subscriptions__allowance_detail_active_allowee" = "تُدفع طلباتك ضمن هذه الحدود تلقائيًا."; +"subscriptions__allowance_detail_ended" = "انتهى هذا المخصص. سيتطلب كل طلب موافقتك من جديد."; +"subscriptions__allowance_error_not_linked" = "جهة الاتصال هذه غير متصلة بعد. حاول مرة أخرى بعد قليل."; +"subscriptions__allowance_error_unavailable" = "هذا المخصص غير متاح حاليًا."; +"subscriptions__allowance_payment_in_progress" = "يجري دفع هذا الطلب بالفعل."; +"subscriptions__allowance_executed_title" = "تم تنفيذ الدفع"; +"subscriptions__allowance_executed_description" = "أرسل الدفع التلقائي {amount} إلى {name}"; +"subscriptions__allowance_limit_title" = "تم بلوغ الحد"; +"subscriptions__allowance_limit_description" = "طلب بقيمة {amount} من {name} يتجاوز مخصصك. راجعه للدفع."; +"subscriptions__allowance_auto_paid" = "مدفوع تلقائيًا"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 640207c62..c0509d9f8 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1764,3 +1764,50 @@ "usdt__deposit_tron_refund_note" = "Les devolucions de TRON poden requerir l'assistència d'un operador d'Orchestra."; "usdt__deposit_refund_address_network" = "Adreça USDT a {network}"; "wallet__send_error_support" = "Contacta amb el suport"; +"subscriptions__allowances" = "Assignacions"; +"subscriptions__allowances_empty_headline" = "Configura\nassignacions"; +"subscriptions__allowances_empty_description" = "Pots establir límits de despesa per pagar automàticament les sol·licituds de pagament de contactes de confiança."; +"subscriptions__allowance_add" = "Afegir assignació"; +"subscriptions__allowance_title" = "Assignació"; +"subscriptions__allowance_choose_contact" = "Triar contacte"; +"subscriptions__allowance_no_contacts" = "Afegeix primer un contacte. Les assignacions cobreixen les sol·licituds de pagament dels teus contactes."; +"subscriptions__allowance_set_title" = "Establir assignació"; +"subscriptions__allowance_set_explanation" = "Aprova automàticament les sol·licituds de pagament de {name} per sota d'aquests límits:"; +"subscriptions__allowance_payment_limit" = "Límit per pagament"; +"subscriptions__allowance_monthly_allowance" = "Assignació mensual"; +"subscriptions__allowance_set_summary" = "Les sol·licituds de fins a {perPayment} es pagaran automàticament, fins a {monthly} al mes, sense preguntar-te cada vegada."; +"subscriptions__allowance_save" = "Desar assignació"; +"subscriptions__allowance_monthly_limit" = "Límit mensual"; +"subscriptions__allowance_per_payment_short" = "{amount} per pagament"; +"subscriptions__allowance_per_payment" = "Per pagament"; +"subscriptions__allowance_each_month" = "Cada mes"; +"subscriptions__allowance_up_to" = "Fins a {amount}"; +"subscriptions__allowance_status_active" = "Activa"; +"subscriptions__allowance_status_waiting" = "Esperant resposta"; +"subscriptions__allowance_status_needs_answer" = "Esperant la teva resposta"; +"subscriptions__allowance_status_scheduled" = "Encara no activa"; +"subscriptions__allowance_status_expired" = "Caducada"; +"subscriptions__allowance_status_declined" = "Rebutjada"; +"subscriptions__allowance_status_conflicted" = "Requereix atenció"; +"subscriptions__allowance_status_ended" = "Finalitzada"; +"subscriptions__allowance_paid_automatically" = "{amount} pagat automàticament"; +"subscriptions__allowance_paid_so_far" = "Pagat automàticament"; +"subscriptions__allowance_offer_headline" = "{name} t'ofereix\nuna assignació"; +"subscriptions__allowance_request_headline" = "{name} demana\nuna assignació"; +"subscriptions__allowance_offer_explanation" = "La cartera de {name} pagarà les teves sol·licituds dins d'aquests límits sense preguntar cada vegada. Qualsevol de vosaltres hi pot posar fi."; +"subscriptions__allowance_request_explanation" = "La teva cartera pagarà les sol·licituds de {name} dins d'aquests límits sense preguntar-te cada vegada. Qualsevol de vosaltres hi pot posar fi."; +"subscriptions__allowance_accept" = "Acceptar"; +"subscriptions__allowance_decline" = "Rebutjar"; +"subscriptions__allowance_swipe_end" = "Desplaça per finalitzar l'assignació"; +"subscriptions__allowance_swipe_withdraw" = "Desplaça per retirar"; +"subscriptions__allowance_detail_active_allower" = "Les sol·licituds dins d'aquests límits es paguen automàticament. Qualsevol import superior et demanarà confirmació abans."; +"subscriptions__allowance_detail_active_allowee" = "Les teves sol·licituds dins d'aquests límits es paguen automàticament."; +"subscriptions__allowance_detail_ended" = "Aquesta assignació ha finalitzat. Cada sol·licitud tornarà a demanar confirmació."; +"subscriptions__allowance_error_not_linked" = "Aquest contacte encara no està connectat. Torna-ho a provar d'aquí a un moment."; +"subscriptions__allowance_error_unavailable" = "Aquesta assignació no està disponible ara mateix."; +"subscriptions__allowance_payment_in_progress" = "Aquesta sol·licitud ja s'està pagant."; +"subscriptions__allowance_executed_title" = "Pagament executat"; +"subscriptions__allowance_executed_description" = "El pagament automàtic ha enviat {amount} a {name}"; +"subscriptions__allowance_limit_title" = "Límit assolit"; +"subscriptions__allowance_limit_description" = "Una sol·licitud de {amount} de {name} supera la teva assignació. Revisa-la per pagar."; +"subscriptions__allowance_auto_paid" = "Pagat automàticament"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index b28cb1392..6dd33eeb9 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1903,3 +1903,50 @@ "usdt__deposit_refund_confirmation" = "Požádat o vrácení vkladu {amount} USDT?\n\nSíť:\n{network}\n\nAdresa pro vrácení:\n{address}\n\nPotvrďte, že tato adresa může přijímat USDT v síti {shortNetwork}. Mohou být účtovány poplatky za vrácení."; "usdt__deposit_tron_refund_note" = "Vrácení prostředků v síti TRON může vyžadovat pomoc operátora Orchestra."; "usdt__deposit_refund_address_network" = "Adresa USDT v síti {network}"; +"subscriptions__allowances" = "Povolení plateb"; +"subscriptions__allowances_empty_headline" = "Nastavte\npovolení plateb"; +"subscriptions__allowances_empty_description" = "Můžete nastavit limity útraty a automaticky tak vyřizovat žádosti o platbu od důvěryhodných kontaktů."; +"subscriptions__allowance_add" = "Přidat povolení"; +"subscriptions__allowance_title" = "Povolení plateb"; +"subscriptions__allowance_choose_contact" = "Vybrat kontakt"; +"subscriptions__allowance_no_contacts" = "Nejprve přidejte kontakt. Povolení plateb se vztahují na žádosti o platbu od vašich kontaktů."; +"subscriptions__allowance_set_title" = "Nastavit povolení"; +"subscriptions__allowance_set_explanation" = "Automaticky schvalovat žádosti o platbu od {name} do těchto limitů:"; +"subscriptions__allowance_payment_limit" = "Limit platby"; +"subscriptions__allowance_monthly_allowance" = "Měsíční povolená částka"; +"subscriptions__allowance_set_summary" = "Žádosti do {perPayment} budou placeny automaticky, maximálně {monthly} měsíčně, aniž bychom se vás pokaždé ptali."; +"subscriptions__allowance_save" = "Uložit povolení"; +"subscriptions__allowance_monthly_limit" = "Měsíční limit"; +"subscriptions__allowance_per_payment_short" = "{amount} za platbu"; +"subscriptions__allowance_per_payment" = "Za platbu"; +"subscriptions__allowance_each_month" = "Každý měsíc"; +"subscriptions__allowance_up_to" = "Až {amount}"; +"subscriptions__allowance_status_active" = "Aktivní"; +"subscriptions__allowance_status_waiting" = "Čeká na odpověď"; +"subscriptions__allowance_status_needs_answer" = "Čeká na vaši odpověď"; +"subscriptions__allowance_status_scheduled" = "Zatím neaktivní"; +"subscriptions__allowance_status_expired" = "Vypršelo"; +"subscriptions__allowance_status_declined" = "Odmítnuto"; +"subscriptions__allowance_status_conflicted" = "Vyžaduje pozornost"; +"subscriptions__allowance_status_ended" = "Ukončeno"; +"subscriptions__allowance_paid_automatically" = "{amount} zaplaceno automaticky"; +"subscriptions__allowance_paid_so_far" = "Zaplaceno automaticky"; +"subscriptions__allowance_offer_headline" = "{name} nabízí\npovolení plateb"; +"subscriptions__allowance_request_headline" = "{name} žádá o\npovolení plateb"; +"subscriptions__allowance_offer_explanation" = "Peněženka {name} bude platit vaše žádosti do těchto limitů, aniž by se pokaždé ptala. Kdokoli z vás ho může ukončit."; +"subscriptions__allowance_request_explanation" = "Vaše peněženka bude platit žádosti {name} do těchto limitů, aniž by se vás pokaždé ptala. Kdokoli z vás ho může ukončit."; +"subscriptions__allowance_accept" = "Přijmout"; +"subscriptions__allowance_decline" = "Odmítnout"; +"subscriptions__allowance_swipe_end" = "Přejeďte pro ukončení povolení"; +"subscriptions__allowance_swipe_withdraw" = "Přejeďte pro stažení"; +"subscriptions__allowance_detail_active_allower" = "Žádosti do těchto limitů se platí automaticky. U čehokoli nad nimi se vás nejprve zeptáme."; +"subscriptions__allowance_detail_active_allowee" = "Vaše žádosti do těchto limitů se platí automaticky."; +"subscriptions__allowance_detail_ended" = "Toto povolení skončilo. Každá žádost bude opět vyžadovat potvrzení."; +"subscriptions__allowance_error_not_linked" = "Tento kontakt zatím není propojen. Zkuste to znovu za chvíli."; +"subscriptions__allowance_error_unavailable" = "Toto povolení není momentálně k dispozici."; +"subscriptions__allowance_payment_in_progress" = "Tato žádost se již platí."; +"subscriptions__allowance_executed_title" = "Platba provedena"; +"subscriptions__allowance_executed_description" = "Automatická platba odeslala {amount} uživateli {name}"; +"subscriptions__allowance_limit_title" = "Limit dosažen"; +"subscriptions__allowance_limit_description" = "Žádost o {amount} od {name} přesahuje vaše povolení. Zkontrolujte ji a zaplaťte."; +"subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index e082793b2..ac1f3bf3a 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1900,3 +1900,50 @@ "usdt__deposit_refund_confirmation" = "Rückerstattung für die Einzahlung von {amount} USDT anfordern?\n\nNetzwerk:\n{network}\n\nRückerstattungsadresse:\n{address}\n\nStelle sicher, dass diese Adresse USDT auf {shortNetwork} empfangen kann. Es können Rückerstattungsgebühren anfallen."; "usdt__deposit_tron_refund_note" = "TRON-Rückerstattungen können die Unterstützung eines Orchestra-Betreibers erfordern."; "usdt__deposit_refund_address_network" = "USDT-Adresse auf {network}"; +"subscriptions__allowances" = "Kontingente"; +"subscriptions__allowances_empty_headline" = "Kontingente\neinrichten"; +"subscriptions__allowances_empty_description" = "Lege Ausgabenlimits fest, um Zahlungsanforderungen vertrauenswürdiger Kontakte automatisch zu bezahlen."; +"subscriptions__allowance_add" = "Kontingent hinzufügen"; +"subscriptions__allowance_title" = "Kontingent"; +"subscriptions__allowance_choose_contact" = "Kontakt auswählen"; +"subscriptions__allowance_no_contacts" = "Füge zuerst einen Kontakt hinzu. Kontingente gelten für Zahlungsanforderungen deiner Kontakte."; +"subscriptions__allowance_set_title" = "Kontingent festlegen"; +"subscriptions__allowance_set_explanation" = "Zahlungsanforderungen von {name} unterhalb dieser Limits automatisch genehmigen:"; +"subscriptions__allowance_payment_limit" = "Zahlungslimit"; +"subscriptions__allowance_monthly_allowance" = "Monatliches Kontingent"; +"subscriptions__allowance_set_summary" = "Anforderungen bis {perPayment} werden automatisch bezahlt, bis zu {monthly} im Monat, ohne dich jedes Mal zu fragen."; +"subscriptions__allowance_save" = "Kontingent speichern"; +"subscriptions__allowance_monthly_limit" = "Monatliches Limit"; +"subscriptions__allowance_per_payment_short" = "{amount} pro Zahlung"; +"subscriptions__allowance_per_payment" = "Pro Zahlung"; +"subscriptions__allowance_each_month" = "Jeden Monat"; +"subscriptions__allowance_up_to" = "Bis zu {amount}"; +"subscriptions__allowance_status_active" = "Aktiv"; +"subscriptions__allowance_status_waiting" = "Wartet auf Antwort"; +"subscriptions__allowance_status_needs_answer" = "Wartet auf deine Antwort"; +"subscriptions__allowance_status_scheduled" = "Noch nicht aktiv"; +"subscriptions__allowance_status_expired" = "Abgelaufen"; +"subscriptions__allowance_status_declined" = "Abgelehnt"; +"subscriptions__allowance_status_conflicted" = "Prüfung nötig"; +"subscriptions__allowance_status_ended" = "Beendet"; +"subscriptions__allowance_paid_automatically" = "{amount} automatisch bezahlt"; +"subscriptions__allowance_paid_so_far" = "Automatisch bezahlt"; +"subscriptions__allowance_offer_headline" = "{name} bietet dir\nein Kontingent"; +"subscriptions__allowance_request_headline" = "{name} bittet um\nein Kontingent"; +"subscriptions__allowance_offer_explanation" = "Das Wallet von {name} bezahlt deine Anforderungen innerhalb dieser Limits, ohne jedes Mal nachzufragen. Jeder von euch kann es beenden."; +"subscriptions__allowance_request_explanation" = "Dein Wallet bezahlt die Anforderungen von {name} innerhalb dieser Limits, ohne dich jedes Mal zu fragen. Jeder von euch kann es beenden."; +"subscriptions__allowance_accept" = "Annehmen"; +"subscriptions__allowance_decline" = "Ablehnen"; +"subscriptions__allowance_swipe_end" = "Wischen zum Beenden"; +"subscriptions__allowance_swipe_withdraw" = "Wischen zum Zurückziehen"; +"subscriptions__allowance_detail_active_allower" = "Anforderungen innerhalb dieser Limits werden automatisch bezahlt. Alles darüber musst du zuerst bestätigen."; +"subscriptions__allowance_detail_active_allowee" = "Deine Anforderungen innerhalb dieser Limits werden automatisch bezahlt."; +"subscriptions__allowance_detail_ended" = "Dieses Kontingent ist beendet. Jede Anforderung muss wieder bestätigt werden."; +"subscriptions__allowance_error_not_linked" = "Dieser Kontakt ist noch nicht verbunden. Versuche es gleich noch einmal."; +"subscriptions__allowance_error_unavailable" = "Dieses Kontingent ist gerade nicht verfügbar."; +"subscriptions__allowance_payment_in_progress" = "Diese Anforderung wird bereits bezahlt."; +"subscriptions__allowance_executed_title" = "Zahlung ausgeführt"; +"subscriptions__allowance_executed_description" = "Auto-Pay hat {amount} an {name} gesendet"; +"subscriptions__allowance_limit_title" = "Limit erreicht"; +"subscriptions__allowance_limit_description" = "Eine Anforderung über {amount} von {name} übersteigt dein Kontingent. Prüfe sie, um zu bezahlen."; +"subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index a417f60da..6ac33f347 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1635,3 +1635,50 @@ "usdt__deposit_tron_refund_note" = "Οι επιστροφές στο TRON μπορεί να απαιτούν βοήθεια από χειριστή του Orchestra."; "usdt__deposit_refund_address_network" = "Διεύθυνση USDT στο {network}"; "wallet__send_error_support" = "Επικοινωνία με Υποστήριξη"; +"subscriptions__allowances" = "Προεγκρίσεις"; +"subscriptions__allowances_empty_headline" = "Ρύθμισε\nπροεγκρίσεις"; +"subscriptions__allowances_empty_description" = "Μπορείς να ορίσεις όρια δαπανών για να εξοφλούνται αυτόματα αιτήματα πληρωμής από αξιόπιστες επαφές."; +"subscriptions__allowance_add" = "Προσθήκη προέγκρισης"; +"subscriptions__allowance_title" = "Προέγκριση"; +"subscriptions__allowance_choose_contact" = "Επιλογή επαφής"; +"subscriptions__allowance_no_contacts" = "Πρόσθεσε πρώτα μια επαφή. Οι προεγκρίσεις καλύπτουν αιτήματα πληρωμής από τις επαφές σου."; +"subscriptions__allowance_set_title" = "Ορισμός προέγκρισης"; +"subscriptions__allowance_set_explanation" = "Αυτόματη έγκριση αιτημάτων πληρωμής από {name} κάτω από αυτά τα όρια:"; +"subscriptions__allowance_payment_limit" = "Όριο πληρωμής"; +"subscriptions__allowance_monthly_allowance" = "Μηνιαίο ποσό"; +"subscriptions__allowance_set_summary" = "Τα αιτήματα έως {perPayment} θα πληρώνονται αυτόματα, έως {monthly} τον μήνα, χωρίς να σε ρωτάμε κάθε φορά."; +"subscriptions__allowance_save" = "Αποθήκευση προέγκρισης"; +"subscriptions__allowance_monthly_limit" = "Μηνιαίο όριο"; +"subscriptions__allowance_per_payment_short" = "{amount} ανά πληρωμή"; +"subscriptions__allowance_per_payment" = "Ανά πληρωμή"; +"subscriptions__allowance_each_month" = "Κάθε μήνα"; +"subscriptions__allowance_up_to" = "Έως {amount}"; +"subscriptions__allowance_status_active" = "Ενεργή"; +"subscriptions__allowance_status_waiting" = "Αναμονή απάντησης"; +"subscriptions__allowance_status_needs_answer" = "Αναμένεται η απάντησή σου"; +"subscriptions__allowance_status_scheduled" = "Δεν είναι ακόμη ενεργή"; +"subscriptions__allowance_status_expired" = "Έληξε"; +"subscriptions__allowance_status_declined" = "Απορρίφθηκε"; +"subscriptions__allowance_status_conflicted" = "Χρειάζεται προσοχή"; +"subscriptions__allowance_status_ended" = "Τερματίστηκε"; +"subscriptions__allowance_paid_automatically" = "{amount} πληρώθηκαν αυτόματα"; +"subscriptions__allowance_paid_so_far" = "Πληρώθηκαν αυτόματα"; +"subscriptions__allowance_offer_headline" = "{name} προσφέρει\nμια προέγκριση"; +"subscriptions__allowance_request_headline" = "{name} ζητά\nμια προέγκριση"; +"subscriptions__allowance_offer_explanation" = "Το πορτοφόλι του/της {name} θα πληρώνει τα αιτήματά σου εντός αυτών των ορίων χωρίς να ρωτά κάθε φορά. Οποιοσδήποτε από εσάς μπορεί να την τερματίσει."; +"subscriptions__allowance_request_explanation" = "Το πορτοφόλι σου θα πληρώνει τα αιτήματα του/της {name} εντός αυτών των ορίων χωρίς να σε ρωτά κάθε φορά. Οποιοσδήποτε από εσάς μπορεί να την τερματίσει."; +"subscriptions__allowance_accept" = "Αποδοχή"; +"subscriptions__allowance_decline" = "Απόρριψη"; +"subscriptions__allowance_swipe_end" = "Σύρε για τερματισμό προέγκρισης"; +"subscriptions__allowance_swipe_withdraw" = "Σύρε για απόσυρση"; +"subscriptions__allowance_detail_active_allower" = "Τα αιτήματα εντός αυτών των ορίων πληρώνονται αυτόματα. Για οτιδήποτε τα υπερβαίνει, θα ερωτηθείς πρώτα."; +"subscriptions__allowance_detail_active_allowee" = "Τα αιτήματά σου εντός αυτών των ορίων πληρώνονται αυτόματα."; +"subscriptions__allowance_detail_ended" = "Αυτή η προέγκριση έχει τερματιστεί. Κάθε αίτημα θα χρειάζεται ξανά έγκριση."; +"subscriptions__allowance_error_not_linked" = "Αυτή η επαφή δεν έχει συνδεθεί ακόμη. Δοκίμασε ξανά σε λίγο."; +"subscriptions__allowance_error_unavailable" = "Αυτή η προέγκριση δεν είναι διαθέσιμη αυτή τη στιγμή."; +"subscriptions__allowance_payment_in_progress" = "Αυτό το αίτημα πληρώνεται ήδη."; +"subscriptions__allowance_executed_title" = "Η πληρωμή εκτελέστηκε"; +"subscriptions__allowance_executed_description" = "Η αυτόματη πληρωμή έστειλε {amount} σε {name}"; +"subscriptions__allowance_limit_title" = "Το όριο εξαντλήθηκε"; +"subscriptions__allowance_limit_description" = "Ένα αίτημα {amount} από {name} υπερβαίνει την προέγκρισή σου. Έλεγξέ το για να πληρώσεις."; +"subscriptions__allowance_auto_paid" = "Πληρώθηκε αυτόματα"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index ba9bf100c..c49ada846 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1906,3 +1906,50 @@ "usdt__deposit_refund_confirmation" = "¿Solicitar el reembolso del depósito de {amount} USDT?\n\nRed:\n{network}\n\nDirección de reembolso:\n{address}\n\nConfirme que esta dirección puede recibir USDT en {shortNetwork}. Es posible que se apliquen comisiones de reembolso."; "usdt__deposit_tron_refund_note" = "Los reembolsos en TRON pueden requerir la asistencia del operador de Orchestra."; "usdt__deposit_refund_address_network" = "Dirección USDT en {network}"; +"subscriptions__allowances" = "Asignaciones"; +"subscriptions__allowances_empty_headline" = "Configure\nasignaciones"; +"subscriptions__allowances_empty_description" = "Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza."; +"subscriptions__allowance_add" = "Añadir asignación"; +"subscriptions__allowance_title" = "Asignación"; +"subscriptions__allowance_choose_contact" = "Elegir contacto"; +"subscriptions__allowance_no_contacts" = "Primero añada un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos."; +"subscriptions__allowance_set_title" = "Establecer asignación"; +"subscriptions__allowance_set_explanation" = "Aprobar automáticamente las solicitudes de pago de {name} por debajo de estos límites:"; +"subscriptions__allowance_payment_limit" = "Límite por pago"; +"subscriptions__allowance_monthly_allowance" = "Asignación mensual"; +"subscriptions__allowance_set_summary" = "Las solicitudes de hasta {perPayment} se pagarán automáticamente, hasta {monthly} por mes, sin preguntarle cada vez."; +"subscriptions__allowance_save" = "Guardar asignación"; +"subscriptions__allowance_monthly_limit" = "Límite mensual"; +"subscriptions__allowance_per_payment_short" = "{amount} por pago"; +"subscriptions__allowance_per_payment" = "Por pago"; +"subscriptions__allowance_each_month" = "Cada mes"; +"subscriptions__allowance_up_to" = "Hasta {amount}"; +"subscriptions__allowance_status_active" = "Activa"; +"subscriptions__allowance_status_waiting" = "Esperando respuesta"; +"subscriptions__allowance_status_needs_answer" = "Esperando su respuesta"; +"subscriptions__allowance_status_scheduled" = "Aún no activa"; +"subscriptions__allowance_status_expired" = "Vencida"; +"subscriptions__allowance_status_declined" = "Rechazada"; +"subscriptions__allowance_status_conflicted" = "Requiere atención"; +"subscriptions__allowance_status_ended" = "Finalizada"; +"subscriptions__allowance_paid_automatically" = "{amount} pagado automáticamente"; +"subscriptions__allowance_paid_so_far" = "Pagado automáticamente"; +"subscriptions__allowance_offer_headline" = "{name} le ofrece\nuna asignación"; +"subscriptions__allowance_request_headline" = "{name} solicita\nuna asignación"; +"subscriptions__allowance_offer_explanation" = "La billetera de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla."; +"subscriptions__allowance_request_explanation" = "Su billetera pagará las solicitudes de {name} dentro de estos límites sin preguntarle cada vez. Cualquiera de los dos puede finalizarla."; +"subscriptions__allowance_accept" = "Aceptar"; +"subscriptions__allowance_decline" = "Rechazar"; +"subscriptions__allowance_swipe_end" = "Deslice para finalizar la asignación"; +"subscriptions__allowance_swipe_withdraw" = "Deslice para retirar"; +"subscriptions__allowance_detail_active_allower" = "Las solicitudes dentro de estos límites se pagan automáticamente. Para cualquier monto superior se le preguntará antes."; +"subscriptions__allowance_detail_active_allowee" = "Sus solicitudes dentro de estos límites se pagan automáticamente."; +"subscriptions__allowance_detail_ended" = "Esta asignación finalizó. Cada solicitud volverá a requerir su confirmación."; +"subscriptions__allowance_error_not_linked" = "Este contacto aún no está conectado. Inténtelo de nuevo en un momento."; +"subscriptions__allowance_error_unavailable" = "Esta asignación no está disponible en este momento."; +"subscriptions__allowance_payment_in_progress" = "Esta solicitud ya se está pagando."; +"subscriptions__allowance_executed_title" = "Pago ejecutado"; +"subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}"; +"subscriptions__allowance_limit_title" = "Límite alcanzado"; +"subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; +"subscriptions__allowance_auto_paid" = "Pagado automáticamente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index d9c7913bd..40cbbb900 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1784,3 +1784,50 @@ "usdt__deposit_tron_refund_note" = "Los reembolsos en TRON pueden requerir la asistencia del operador de Orchestra."; "usdt__deposit_refund_address_network" = "Dirección USDT en {network}"; "wallet__send_error_support" = "Contactar con el servicio de asistencia"; +"subscriptions__allowances" = "Asignaciones"; +"subscriptions__allowances_empty_headline" = "Configure\nasignaciones"; +"subscriptions__allowances_empty_description" = "Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza."; +"subscriptions__allowance_add" = "Añadir asignación"; +"subscriptions__allowance_title" = "Asignación"; +"subscriptions__allowance_choose_contact" = "Elegir contacto"; +"subscriptions__allowance_no_contacts" = "Añada primero un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos."; +"subscriptions__allowance_set_title" = "Establecer asignación"; +"subscriptions__allowance_set_explanation" = "Aprobar automáticamente las solicitudes de pago de {name} por debajo de estos límites:"; +"subscriptions__allowance_payment_limit" = "Límite por pago"; +"subscriptions__allowance_monthly_allowance" = "Asignación mensual"; +"subscriptions__allowance_set_summary" = "Las solicitudes de hasta {perPayment} se pagarán automáticamente, hasta {monthly} al mes, sin preguntarle cada vez."; +"subscriptions__allowance_save" = "Guardar asignación"; +"subscriptions__allowance_monthly_limit" = "Límite mensual"; +"subscriptions__allowance_per_payment_short" = "{amount} por pago"; +"subscriptions__allowance_per_payment" = "Por pago"; +"subscriptions__allowance_each_month" = "Cada mes"; +"subscriptions__allowance_up_to" = "Hasta {amount}"; +"subscriptions__allowance_status_active" = "Activa"; +"subscriptions__allowance_status_waiting" = "Esperando respuesta"; +"subscriptions__allowance_status_needs_answer" = "Esperando su respuesta"; +"subscriptions__allowance_status_scheduled" = "Aún no activa"; +"subscriptions__allowance_status_expired" = "Caducada"; +"subscriptions__allowance_status_declined" = "Rechazada"; +"subscriptions__allowance_status_conflicted" = "Requiere atención"; +"subscriptions__allowance_status_ended" = "Finalizada"; +"subscriptions__allowance_paid_automatically" = "{amount} pagado automáticamente"; +"subscriptions__allowance_paid_so_far" = "Pagado automáticamente"; +"subscriptions__allowance_offer_headline" = "{name} le ofrece\nuna asignación"; +"subscriptions__allowance_request_headline" = "{name} solicita\nuna asignación"; +"subscriptions__allowance_offer_explanation" = "El monedero de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla."; +"subscriptions__allowance_request_explanation" = "Su monedero pagará las solicitudes de {name} dentro de estos límites sin preguntarle cada vez. Cualquiera de los dos puede finalizarla."; +"subscriptions__allowance_accept" = "Aceptar"; +"subscriptions__allowance_decline" = "Rechazar"; +"subscriptions__allowance_swipe_end" = "Deslizar para finalizar la asignación"; +"subscriptions__allowance_swipe_withdraw" = "Deslizar para retirar"; +"subscriptions__allowance_detail_active_allower" = "Las solicitudes dentro de estos límites se pagan automáticamente. Para cualquier importe superior se le preguntará antes."; +"subscriptions__allowance_detail_active_allowee" = "Sus solicitudes dentro de estos límites se pagan automáticamente."; +"subscriptions__allowance_detail_ended" = "Esta asignación ha finalizado. Cada solicitud volverá a requerir su confirmación."; +"subscriptions__allowance_error_not_linked" = "Este contacto aún no está conectado. Inténtelo de nuevo en un momento."; +"subscriptions__allowance_error_unavailable" = "Esta asignación no está disponible en este momento."; +"subscriptions__allowance_payment_in_progress" = "Esta solicitud ya se está pagando."; +"subscriptions__allowance_executed_title" = "Pago ejecutado"; +"subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}"; +"subscriptions__allowance_limit_title" = "Límite alcanzado"; +"subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; +"subscriptions__allowance_auto_paid" = "Pagado automáticamente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 52e840f68..00df90320 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1914,3 +1914,50 @@ "usdt__deposit_refund_confirmation" = "Demander le remboursement du dépôt de {amount} USDT ?\n\nRéseau :\n{network}\n\nAdresse de remboursement :\n{address}\n\nVérifiez que cette adresse peut recevoir des USDT sur {shortNetwork}. Des frais de remboursement peuvent s'appliquer."; "usdt__deposit_tron_refund_note" = "Les remboursements TRON peuvent nécessiter l'intervention d'un opérateur Orchestra."; "usdt__deposit_refund_address_network" = "Adresse USDT sur {network}"; +"subscriptions__allowances" = "Allocations"; +"subscriptions__allowances_empty_headline" = "Configurez\ndes allocations"; +"subscriptions__allowances_empty_description" = "Vous pouvez définir des limites de dépenses pour régler automatiquement les demandes de paiement de contacts de confiance."; +"subscriptions__allowance_add" = "Ajouter une allocation"; +"subscriptions__allowance_title" = "Allocation"; +"subscriptions__allowance_choose_contact" = "Choisir un contact"; +"subscriptions__allowance_no_contacts" = "Ajoutez d'abord un contact. Les allocations couvrent les demandes de paiement de vos contacts."; +"subscriptions__allowance_set_title" = "Définir l'allocation"; +"subscriptions__allowance_set_explanation" = "Approuver automatiquement les demandes de paiement de {name} sous ces limites :"; +"subscriptions__allowance_payment_limit" = "Limite par paiement"; +"subscriptions__allowance_monthly_allowance" = "Allocation mensuelle"; +"subscriptions__allowance_set_summary" = "Les demandes jusqu'à {perPayment} seront payées automatiquement, jusqu'à {monthly} par mois, sans vous demander à chaque fois."; +"subscriptions__allowance_save" = "Enregistrer l'allocation"; +"subscriptions__allowance_monthly_limit" = "Limite mensuelle"; +"subscriptions__allowance_per_payment_short" = "{amount} par paiement"; +"subscriptions__allowance_per_payment" = "Par paiement"; +"subscriptions__allowance_each_month" = "Chaque mois"; +"subscriptions__allowance_up_to" = "Jusqu'à {amount}"; +"subscriptions__allowance_status_active" = "Active"; +"subscriptions__allowance_status_waiting" = "En attente de réponse"; +"subscriptions__allowance_status_needs_answer" = "En attente de votre réponse"; +"subscriptions__allowance_status_scheduled" = "Pas encore active"; +"subscriptions__allowance_status_expired" = "Expirée"; +"subscriptions__allowance_status_declined" = "Refusée"; +"subscriptions__allowance_status_conflicted" = "Action requise"; +"subscriptions__allowance_status_ended" = "Terminée"; +"subscriptions__allowance_paid_automatically" = "{amount} payé automatiquement"; +"subscriptions__allowance_paid_so_far" = "Payé automatiquement"; +"subscriptions__allowance_offer_headline" = "{name} vous propose\nune allocation"; +"subscriptions__allowance_request_headline" = "{name} demande\nune allocation"; +"subscriptions__allowance_offer_explanation" = "Le portefeuille de {name} paiera vos demandes dans ces limites sans demander à chaque fois. Chacun de vous peut y mettre fin."; +"subscriptions__allowance_request_explanation" = "Votre portefeuille paiera les demandes de {name} dans ces limites sans vous demander à chaque fois. Chacun de vous peut y mettre fin."; +"subscriptions__allowance_accept" = "Accepter"; +"subscriptions__allowance_decline" = "Refuser"; +"subscriptions__allowance_swipe_end" = "Glissez pour mettre fin à l'allocation"; +"subscriptions__allowance_swipe_withdraw" = "Glissez pour retirer"; +"subscriptions__allowance_detail_active_allower" = "Les demandes dans ces limites sont payées automatiquement. Au-delà, votre accord vous sera d'abord demandé."; +"subscriptions__allowance_detail_active_allowee" = "Vos demandes dans ces limites sont payées automatiquement."; +"subscriptions__allowance_detail_ended" = "Cette allocation a pris fin. Chaque demande nécessite à nouveau votre accord."; +"subscriptions__allowance_error_not_linked" = "Ce contact n'est pas encore connecté. Réessayez dans un instant."; +"subscriptions__allowance_error_unavailable" = "Cette allocation n'est pas disponible pour le moment."; +"subscriptions__allowance_payment_in_progress" = "Cette demande est déjà en cours de paiement."; +"subscriptions__allowance_executed_title" = "Paiement effectué"; +"subscriptions__allowance_executed_description" = "Le paiement automatique a envoyé {amount} à {name}"; +"subscriptions__allowance_limit_title" = "Limite atteinte"; +"subscriptions__allowance_limit_description" = "Une demande de {amount} de {name} dépasse votre allocation. Examinez-la pour payer."; +"subscriptions__allowance_auto_paid" = "Payé automatiquement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index a65e4b51e..239133b7a 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1873,3 +1873,50 @@ "usdt__deposit_refund_confirmation" = "Richiedere il rimborso del deposito di {amount} USDT?\n\nRete:\n{network}\n\nIndirizzo di rimborso:\n{address}\n\nVerifica che questo indirizzo possa ricevere USDT su {shortNetwork}. Potrebbero essere applicate commissioni di rimborso."; "usdt__deposit_tron_refund_note" = "I rimborsi TRON possono richiedere l'intervento di un operatore Orchestra."; "usdt__deposit_refund_address_network" = "Indirizzo USDT su {network}"; +"subscriptions__allowances" = "Plafond"; +"subscriptions__allowances_empty_headline" = "Imposta\ni plafond"; +"subscriptions__allowances_empty_description" = "Puoi impostare limiti di spesa per pagare automaticamente le richieste di pagamento dei contatti fidati."; +"subscriptions__allowance_add" = "Aggiungi Plafond"; +"subscriptions__allowance_title" = "Plafond"; +"subscriptions__allowance_choose_contact" = "Scegli Contatto"; +"subscriptions__allowance_no_contacts" = "Aggiungi prima un contatto. I plafond coprono le richieste di pagamento dei tuoi contatti."; +"subscriptions__allowance_set_title" = "Imposta Plafond"; +"subscriptions__allowance_set_explanation" = "Approva automaticamente le richieste di pagamento di {name} entro questi limiti:"; +"subscriptions__allowance_payment_limit" = "Limite per pagamento"; +"subscriptions__allowance_monthly_allowance" = "Plafond mensile"; +"subscriptions__allowance_set_summary" = "Le richieste fino a {perPayment} verranno pagate automaticamente, fino a {monthly} al mese, senza chiederti ogni volta."; +"subscriptions__allowance_save" = "Salva Plafond"; +"subscriptions__allowance_monthly_limit" = "Limite mensile"; +"subscriptions__allowance_per_payment_short" = "{amount} a pagamento"; +"subscriptions__allowance_per_payment" = "Per pagamento"; +"subscriptions__allowance_each_month" = "Ogni mese"; +"subscriptions__allowance_up_to" = "Fino a {amount}"; +"subscriptions__allowance_status_active" = "Attivo"; +"subscriptions__allowance_status_waiting" = "In attesa di risposta"; +"subscriptions__allowance_status_needs_answer" = "In attesa della tua risposta"; +"subscriptions__allowance_status_scheduled" = "Non ancora attivo"; +"subscriptions__allowance_status_expired" = "Scaduto"; +"subscriptions__allowance_status_declined" = "Rifiutato"; +"subscriptions__allowance_status_conflicted" = "Richiede attenzione"; +"subscriptions__allowance_status_ended" = "Terminato"; +"subscriptions__allowance_paid_automatically" = "{amount} pagati automaticamente"; +"subscriptions__allowance_paid_so_far" = "Pagato automaticamente"; +"subscriptions__allowance_offer_headline" = "{name} ti offre\nun plafond"; +"subscriptions__allowance_request_headline" = "{name} chiede\nun plafond"; +"subscriptions__allowance_offer_explanation" = "Il wallet di {name} pagherà le tue richieste entro questi limiti senza chiedere ogni volta. Ognuno di voi può terminarlo."; +"subscriptions__allowance_request_explanation" = "Il tuo wallet pagherà le richieste di {name} entro questi limiti senza chiederti ogni volta. Ognuno di voi può terminarlo."; +"subscriptions__allowance_accept" = "Accetta"; +"subscriptions__allowance_decline" = "Rifiuta"; +"subscriptions__allowance_swipe_end" = "Scorri Per Terminare Il Plafond"; +"subscriptions__allowance_swipe_withdraw" = "Scorri Per Ritirare"; +"subscriptions__allowance_detail_active_allower" = "Le richieste entro questi limiti vengono pagate automaticamente. Per importi superiori ti verrà chiesto prima."; +"subscriptions__allowance_detail_active_allowee" = "Le tue richieste entro questi limiti vengono pagate automaticamente."; +"subscriptions__allowance_detail_ended" = "Questo plafond è terminato. Ogni richiesta richiederà di nuovo la tua conferma."; +"subscriptions__allowance_error_not_linked" = "Questo contatto non è ancora collegato. Riprova tra un momento."; +"subscriptions__allowance_error_unavailable" = "Questo plafond non è disponibile al momento."; +"subscriptions__allowance_payment_in_progress" = "Questa richiesta è già in fase di pagamento."; +"subscriptions__allowance_executed_title" = "Pagamento Eseguito"; +"subscriptions__allowance_executed_description" = "Il pagamento automatico ha inviato {amount} a {name}"; +"subscriptions__allowance_limit_title" = "Limite Raggiunto"; +"subscriptions__allowance_limit_description" = "Una richiesta di {amount} da {name} supera il tuo plafond. Controllala per pagare."; +"subscriptions__allowance_auto_paid" = "Pagato automaticamente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 5db94734b..b35b29906 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1908,3 +1908,50 @@ "usdt__deposit_refund_confirmation" = "Terugbetaling aanvragen voor storting van {amount} USDT?\n\nNetwerk:\n{network}\n\nTerugbetalingsadres:\n{address}\n\nControleer of dit adres USDT kan ontvangen op {shortNetwork}. Er kunnen terugbetalingskosten gelden."; "usdt__deposit_tron_refund_note" = "Voor TRON-terugbetalingen kan hulp van een Orchestra-operator nodig zijn."; "usdt__deposit_refund_address_network" = "USDT-adres op {network}"; +"subscriptions__allowances" = "Toelagen"; +"subscriptions__allowances_empty_headline" = "Stel\ntoelagen in"; +"subscriptions__allowances_empty_description" = "U kunt bestedingslimieten instellen om betaalverzoeken van vertrouwde contacten automatisch te voldoen."; +"subscriptions__allowance_add" = "Toelage toevoegen"; +"subscriptions__allowance_title" = "Toelage"; +"subscriptions__allowance_choose_contact" = "Contact kiezen"; +"subscriptions__allowance_no_contacts" = "Voeg eerst een contact toe. Toelagen gelden voor betaalverzoeken van uw contacten."; +"subscriptions__allowance_set_title" = "Toelage instellen"; +"subscriptions__allowance_set_explanation" = "Betaalverzoeken van {name} onder deze limieten automatisch goedkeuren:"; +"subscriptions__allowance_payment_limit" = "Betaallimiet"; +"subscriptions__allowance_monthly_allowance" = "Maandelijkse toelage"; +"subscriptions__allowance_set_summary" = "Verzoeken tot {perPayment} worden automatisch betaald, tot {monthly} per maand, zonder het u telkens te vragen."; +"subscriptions__allowance_save" = "Toelage opslaan"; +"subscriptions__allowance_monthly_limit" = "Maandelijkse limiet"; +"subscriptions__allowance_per_payment_short" = "{amount} per betaling"; +"subscriptions__allowance_per_payment" = "Per betaling"; +"subscriptions__allowance_each_month" = "Elke maand"; +"subscriptions__allowance_up_to" = "Tot {amount}"; +"subscriptions__allowance_status_active" = "Actief"; +"subscriptions__allowance_status_waiting" = "Wacht op antwoord"; +"subscriptions__allowance_status_needs_answer" = "Wacht op uw antwoord"; +"subscriptions__allowance_status_scheduled" = "Nog niet actief"; +"subscriptions__allowance_status_expired" = "Verlopen"; +"subscriptions__allowance_status_declined" = "Geweigerd"; +"subscriptions__allowance_status_conflicted" = "Aandacht nodig"; +"subscriptions__allowance_status_ended" = "Beëindigd"; +"subscriptions__allowance_paid_automatically" = "{amount} automatisch betaald"; +"subscriptions__allowance_paid_so_far" = "Automatisch betaald"; +"subscriptions__allowance_offer_headline" = "{name} biedt\neen toelage aan"; +"subscriptions__allowance_request_headline" = "{name} vraagt om\neen toelage"; +"subscriptions__allowance_offer_explanation" = "De wallet van {name} betaalt uw verzoeken binnen deze limieten zonder telkens te vragen. U kunt deze allebei beëindigen."; +"subscriptions__allowance_request_explanation" = "Uw wallet betaalt de verzoeken van {name} binnen deze limieten zonder het u telkens te vragen. U kunt deze allebei beëindigen."; +"subscriptions__allowance_accept" = "Accepteren"; +"subscriptions__allowance_decline" = "Weigeren"; +"subscriptions__allowance_swipe_end" = "Veeg om toelage te beëindigen"; +"subscriptions__allowance_swipe_withdraw" = "Veeg om in te trekken"; +"subscriptions__allowance_detail_active_allower" = "Verzoeken binnen deze limieten worden automatisch betaald. Voor alles daarboven wordt u eerst om toestemming gevraagd."; +"subscriptions__allowance_detail_active_allowee" = "Uw verzoeken binnen deze limieten worden automatisch betaald."; +"subscriptions__allowance_detail_ended" = "Deze toelage is beëindigd. Elk verzoek vraagt weer om toestemming."; +"subscriptions__allowance_error_not_linked" = "Dit contact is nog niet verbonden. Probeer het zo opnieuw."; +"subscriptions__allowance_error_unavailable" = "Deze toelage is momenteel niet beschikbaar."; +"subscriptions__allowance_payment_in_progress" = "Dit verzoek wordt al betaald."; +"subscriptions__allowance_executed_title" = "Betaling uitgevoerd"; +"subscriptions__allowance_executed_description" = "Automatisch betalen heeft {amount} naar {name} gestuurd"; +"subscriptions__allowance_limit_title" = "Limiet bereikt"; +"subscriptions__allowance_limit_description" = "Een verzoek van {amount} van {name} overschrijdt uw toelage. Bekijk het om te betalen."; +"subscriptions__allowance_auto_paid" = "Automatisch betaald"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index afcd7b4a1..b21506d62 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1913,3 +1913,50 @@ "usdt__deposit_refund_confirmation" = "Poprosić o zwrot wpłaty {amount} USDT?\n\nSieć:\n{network}\n\nAdres zwrotu:\n{address}\n\nUpewnij się, że ten adres może odbierać USDT w sieci {shortNetwork}. Mogą obowiązywać opłaty za zwrot."; "usdt__deposit_tron_refund_note" = "Zwroty w sieci TRON mogą wymagać pomocy operatora Orchestra."; "usdt__deposit_refund_address_network" = "Adres USDT w sieci {network}"; +"subscriptions__allowances" = "Przydziały"; +"subscriptions__allowances_empty_headline" = "Ustaw\nprzydziały"; +"subscriptions__allowances_empty_description" = "Możesz ustawić limity wydatków, aby automatycznie realizować żądania płatności od zaufanych kontaktów."; +"subscriptions__allowance_add" = "Dodaj przydział"; +"subscriptions__allowance_title" = "Przydział"; +"subscriptions__allowance_choose_contact" = "Wybierz kontakt"; +"subscriptions__allowance_no_contacts" = "Najpierw dodaj kontakt. Przydziały obejmują żądania płatności od Twoich kontaktów."; +"subscriptions__allowance_set_title" = "Ustaw przydział"; +"subscriptions__allowance_set_explanation" = "Automatycznie zatwierdzaj żądania płatności od {name} poniżej tych limitów:"; +"subscriptions__allowance_payment_limit" = "Limit płatności"; +"subscriptions__allowance_monthly_allowance" = "Miesięczny przydział"; +"subscriptions__allowance_set_summary" = "Żądania do {perPayment} będą opłacane automatycznie, do {monthly} miesięcznie, bez pytania Cię za każdym razem."; +"subscriptions__allowance_save" = "Zapisz przydział"; +"subscriptions__allowance_monthly_limit" = "Limit miesięczny"; +"subscriptions__allowance_per_payment_short" = "{amount} za płatność"; +"subscriptions__allowance_per_payment" = "Za płatność"; +"subscriptions__allowance_each_month" = "Co miesiąc"; +"subscriptions__allowance_up_to" = "Do {amount}"; +"subscriptions__allowance_status_active" = "Aktywny"; +"subscriptions__allowance_status_waiting" = "Czeka na odpowiedź"; +"subscriptions__allowance_status_needs_answer" = "Czeka na Twoją odpowiedź"; +"subscriptions__allowance_status_scheduled" = "Jeszcze nieaktywny"; +"subscriptions__allowance_status_expired" = "Wygasł"; +"subscriptions__allowance_status_declined" = "Odrzucony"; +"subscriptions__allowance_status_conflicted" = "Wymaga uwagi"; +"subscriptions__allowance_status_ended" = "Zakończony"; +"subscriptions__allowance_paid_automatically" = "{amount} opłacono automatycznie"; +"subscriptions__allowance_paid_so_far" = "Opłacono automatycznie"; +"subscriptions__allowance_offer_headline" = "{name} oferuje\nprzydział"; +"subscriptions__allowance_request_headline" = "{name} prosi o\nprzydział"; +"subscriptions__allowance_offer_explanation" = "Portfel {name} będzie opłacać Twoje żądania w tych limitach bez pytania za każdym razem. Każde z was może go zakończyć."; +"subscriptions__allowance_request_explanation" = "Twój portfel będzie opłacać żądania {name} w tych limitach bez pytania Cię za każdym razem. Każde z was może go zakończyć."; +"subscriptions__allowance_accept" = "Akceptuj"; +"subscriptions__allowance_decline" = "Odrzuć"; +"subscriptions__allowance_swipe_end" = "Przesuń, aby zakończyć przydział"; +"subscriptions__allowance_swipe_withdraw" = "Przesuń, aby wycofać"; +"subscriptions__allowance_detail_active_allower" = "Żądania w tych limitach są opłacane automatycznie. O wszystko powyżej zapytamy Cię najpierw."; +"subscriptions__allowance_detail_active_allowee" = "Twoje żądania w tych limitach są opłacane automatycznie."; +"subscriptions__allowance_detail_ended" = "Ten przydział się zakończył. Każde żądanie znów wymaga potwierdzenia."; +"subscriptions__allowance_error_not_linked" = "Ten kontakt nie jest jeszcze połączony. Spróbuj ponownie za chwilę."; +"subscriptions__allowance_error_unavailable" = "Ten przydział jest teraz niedostępny."; +"subscriptions__allowance_payment_in_progress" = "To żądanie jest już opłacane."; +"subscriptions__allowance_executed_title" = "Płatność wykonana"; +"subscriptions__allowance_executed_description" = "Autopłatność wysłała {amount} do {name}"; +"subscriptions__allowance_limit_title" = "Osiągnięto limit"; +"subscriptions__allowance_limit_description" = "Żądanie {amount} od {name} przekracza Twój przydział. Przejrzyj je, aby zapłacić."; +"subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index c84d25a17..bed11492c 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1914,3 +1914,50 @@ "usdt__deposit_refund_confirmation" = "Solicitar reembolso do depósito de {amount} USDT?\n\nRede:\n{network}\n\nEndereço de reembolso:\n{address}\n\nConfirme que este endereço pode receber USDT na rede {shortNetwork}. Taxas de reembolso podem ser cobradas."; "usdt__deposit_tron_refund_note" = "Reembolsos na TRON podem exigir assistência do operador da Orchestra."; "usdt__deposit_refund_address_network" = "Endereço USDT na rede {network}"; +"subscriptions__allowances" = "Pré-aprovações"; +"subscriptions__allowances_empty_headline" = "Configure\npré-aprovações"; +"subscriptions__allowances_empty_description" = "Você pode definir limites de gastos para pagar automaticamente solicitações de pagamento de contatos confiáveis."; +"subscriptions__allowance_add" = "Adicionar Pré-aprovação"; +"subscriptions__allowance_title" = "Pré-aprovação"; +"subscriptions__allowance_choose_contact" = "Escolher Contato"; +"subscriptions__allowance_no_contacts" = "Adicione um contato primeiro. As pré-aprovações cobrem solicitações de pagamento dos seus contatos."; +"subscriptions__allowance_set_title" = "Definir Pré-aprovação"; +"subscriptions__allowance_set_explanation" = "Aprovar automaticamente solicitações de pagamento de {name} abaixo destes limites:"; +"subscriptions__allowance_payment_limit" = "Limite por pagamento"; +"subscriptions__allowance_monthly_allowance" = "Valor mensal"; +"subscriptions__allowance_set_summary" = "Solicitações de até {perPayment} serão pagas automaticamente, até {monthly} por mês, sem perguntar a você toda vez."; +"subscriptions__allowance_save" = "Salvar Pré-aprovação"; +"subscriptions__allowance_monthly_limit" = "Limite mensal"; +"subscriptions__allowance_per_payment_short" = "{amount} por pagamento"; +"subscriptions__allowance_per_payment" = "Por pagamento"; +"subscriptions__allowance_each_month" = "Todo mês"; +"subscriptions__allowance_up_to" = "Até {amount}"; +"subscriptions__allowance_status_active" = "Ativa"; +"subscriptions__allowance_status_waiting" = "Aguardando resposta"; +"subscriptions__allowance_status_needs_answer" = "Aguardando sua resposta"; +"subscriptions__allowance_status_scheduled" = "Ainda não ativa"; +"subscriptions__allowance_status_expired" = "Expirada"; +"subscriptions__allowance_status_declined" = "Recusada"; +"subscriptions__allowance_status_conflicted" = "Requer atenção"; +"subscriptions__allowance_status_ended" = "Encerrada"; +"subscriptions__allowance_paid_automatically" = "{amount} pago automaticamente"; +"subscriptions__allowance_paid_so_far" = "Pago automaticamente"; +"subscriptions__allowance_offer_headline" = "{name} oferece\numa pré-aprovação"; +"subscriptions__allowance_request_headline" = "{name} pede\numa pré-aprovação"; +"subscriptions__allowance_offer_explanation" = "A carteira de {name} pagará suas solicitações dentro destes limites sem perguntar toda vez. Qualquer um de vocês pode encerrá-la."; +"subscriptions__allowance_request_explanation" = "Sua carteira pagará as solicitações de {name} dentro destes limites sem perguntar a você toda vez. Qualquer um de vocês pode encerrá-la."; +"subscriptions__allowance_accept" = "Aceitar"; +"subscriptions__allowance_decline" = "Recusar"; +"subscriptions__allowance_swipe_end" = "Deslize para Encerrar a Pré-aprovação"; +"subscriptions__allowance_swipe_withdraw" = "Deslize para Retirar"; +"subscriptions__allowance_detail_active_allower" = "Solicitações dentro destes limites são pagas automaticamente. Qualquer valor acima pede sua confirmação antes."; +"subscriptions__allowance_detail_active_allowee" = "Suas solicitações dentro destes limites são pagas automaticamente."; +"subscriptions__allowance_detail_ended" = "Esta pré-aprovação foi encerrada. Cada solicitação volta a pedir confirmação."; +"subscriptions__allowance_error_not_linked" = "Este contato ainda não está conectado. Tente novamente em instantes."; +"subscriptions__allowance_error_unavailable" = "Esta pré-aprovação não está disponível no momento."; +"subscriptions__allowance_payment_in_progress" = "Esta solicitação já está sendo paga."; +"subscriptions__allowance_executed_title" = "Pagamento Executado"; +"subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}"; +"subscriptions__allowance_limit_title" = "Limite Atingido"; +"subscriptions__allowance_limit_description" = "Uma solicitação de {amount} de {name} excede sua pré-aprovação. Revise-a para pagar."; +"subscriptions__allowance_auto_paid" = "Pago automaticamente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 3ba9289c8..6d1c1666a 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1560,3 +1560,50 @@ "usdt__deposit_refund_confirmation" = "Pedir o reembolso do depósito de {amount} USDT?\n\nRede:\n{network}\n\nEndereço de reembolso:\n{address}\n\nConfirme que este endereço pode receber USDT na rede {shortNetwork}. Podem aplicar-se taxas de reembolso."; "usdt__deposit_tron_refund_note" = "Os reembolsos na TRON podem exigir assistência do operador da Orchestra."; "usdt__deposit_refund_address_network" = "Endereço USDT na rede {network}"; +"subscriptions__allowances" = "Pré-aprovações"; +"subscriptions__allowances_empty_headline" = "Configure\npré-aprovações"; +"subscriptions__allowances_empty_description" = "Pode definir limites de gastos para pagar automaticamente pedidos de pagamento de contactos de confiança."; +"subscriptions__allowance_add" = "Adicionar pré-aprovação"; +"subscriptions__allowance_title" = "Pré-aprovação"; +"subscriptions__allowance_choose_contact" = "Escolher contacto"; +"subscriptions__allowance_no_contacts" = "Adicione primeiro um contacto. As pré-aprovações abrangem pedidos de pagamento dos seus contactos."; +"subscriptions__allowance_set_title" = "Definir pré-aprovação"; +"subscriptions__allowance_set_explanation" = "Aprovar automaticamente pedidos de pagamento de {name} abaixo destes limites:"; +"subscriptions__allowance_payment_limit" = "Limite por pagamento"; +"subscriptions__allowance_monthly_allowance" = "Valor mensal"; +"subscriptions__allowance_set_summary" = "Os pedidos até {perPayment} serão pagos automaticamente, até {monthly} por mês, sem lhe perguntar sempre."; +"subscriptions__allowance_save" = "Guardar pré-aprovação"; +"subscriptions__allowance_monthly_limit" = "Limite mensal"; +"subscriptions__allowance_per_payment_short" = "{amount} por pagamento"; +"subscriptions__allowance_per_payment" = "Por pagamento"; +"subscriptions__allowance_each_month" = "Todos os meses"; +"subscriptions__allowance_up_to" = "Até {amount}"; +"subscriptions__allowance_status_active" = "Ativa"; +"subscriptions__allowance_status_waiting" = "A aguardar resposta"; +"subscriptions__allowance_status_needs_answer" = "A aguardar a sua resposta"; +"subscriptions__allowance_status_scheduled" = "Ainda não ativa"; +"subscriptions__allowance_status_expired" = "Expirada"; +"subscriptions__allowance_status_declined" = "Recusada"; +"subscriptions__allowance_status_conflicted" = "Requer atenção"; +"subscriptions__allowance_status_ended" = "Terminada"; +"subscriptions__allowance_paid_automatically" = "{amount} pago automaticamente"; +"subscriptions__allowance_paid_so_far" = "Pago automaticamente"; +"subscriptions__allowance_offer_headline" = "{name} oferece-lhe\numa pré-aprovação"; +"subscriptions__allowance_request_headline" = "{name} pede\numa pré-aprovação"; +"subscriptions__allowance_offer_explanation" = "A carteira de {name} pagará os seus pedidos dentro destes limites sem perguntar sempre. Qualquer um de vós pode terminá-la."; +"subscriptions__allowance_request_explanation" = "A sua carteira pagará os pedidos de {name} dentro destes limites sem lhe perguntar sempre. Qualquer um de vós pode terminá-la."; +"subscriptions__allowance_accept" = "Aceitar"; +"subscriptions__allowance_decline" = "Recusar"; +"subscriptions__allowance_swipe_end" = "Deslize para terminar a pré-aprovação"; +"subscriptions__allowance_swipe_withdraw" = "Deslize para retirar"; +"subscriptions__allowance_detail_active_allower" = "Os pedidos dentro destes limites são pagos automaticamente. Para valores acima, ser-lhe-á pedida confirmação primeiro."; +"subscriptions__allowance_detail_active_allowee" = "Os seus pedidos dentro destes limites são pagos automaticamente."; +"subscriptions__allowance_detail_ended" = "Esta pré-aprovação terminou. Cada pedido volta a pedir confirmação."; +"subscriptions__allowance_error_not_linked" = "Este contacto ainda não está ligado. Tente novamente daqui a pouco."; +"subscriptions__allowance_error_unavailable" = "Esta pré-aprovação não está disponível de momento."; +"subscriptions__allowance_payment_in_progress" = "Este pedido já está a ser pago."; +"subscriptions__allowance_executed_title" = "Pagamento executado"; +"subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}"; +"subscriptions__allowance_limit_title" = "Limite atingido"; +"subscriptions__allowance_limit_description" = "Um pedido de {amount} de {name} excede a sua pré-aprovação. Reveja-o para pagar."; +"subscriptions__allowance_auto_paid" = "Pago automaticamente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index a332ce783..c8cc453c7 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1894,3 +1894,50 @@ "usdt__deposit_refund_confirmation" = "Запросить возврат депозита {amount} USDT?\n\nСеть:\n{network}\n\nАдрес для возврата:\n{address}\n\nУбедитесь, что этот адрес может получать USDT в сети {shortNetwork}. Может взиматься комиссия за возврат."; "usdt__deposit_tron_refund_note" = "Для возвратов в сети TRON может потребоваться помощь оператора Orchestra."; "usdt__deposit_refund_address_network" = "Адрес USDT в сети {network}"; +"subscriptions__allowances" = "Лимиты автооплаты"; +"subscriptions__allowances_empty_headline" = "Настройте\nлимиты автооплаты"; +"subscriptions__allowances_empty_description" = "Вы можете задать лимиты расходов, чтобы автоматически оплачивать запросы на оплату от доверенных контактов."; +"subscriptions__allowance_add" = "Добавить Лимит"; +"subscriptions__allowance_title" = "Лимит автооплаты"; +"subscriptions__allowance_choose_contact" = "Выберите Контакт"; +"subscriptions__allowance_no_contacts" = "Сначала добавьте контакт. Лимиты автооплаты действуют для запросов на оплату от ваших контактов."; +"subscriptions__allowance_set_title" = "Задать Лимит"; +"subscriptions__allowance_set_explanation" = "Автоматически одобрять запросы на оплату от {name} в пределах этих лимитов:"; +"subscriptions__allowance_payment_limit" = "Лимит платежа"; +"subscriptions__allowance_monthly_allowance" = "Месячный лимит автооплаты"; +"subscriptions__allowance_set_summary" = "Запросы до {perPayment} будут оплачиваться автоматически, но не более {monthly} в месяц, без подтверждения каждый раз."; +"subscriptions__allowance_save" = "Сохранить Лимит"; +"subscriptions__allowance_monthly_limit" = "Месячный лимит"; +"subscriptions__allowance_per_payment_short" = "{amount} за платёж"; +"subscriptions__allowance_per_payment" = "За платёж"; +"subscriptions__allowance_each_month" = "Каждый месяц"; +"subscriptions__allowance_up_to" = "До {amount}"; +"subscriptions__allowance_status_active" = "Активен"; +"subscriptions__allowance_status_waiting" = "Ожидает ответа"; +"subscriptions__allowance_status_needs_answer" = "Ожидает вашего ответа"; +"subscriptions__allowance_status_scheduled" = "Ещё не активен"; +"subscriptions__allowance_status_expired" = "Истёк"; +"subscriptions__allowance_status_declined" = "Отклонён"; +"subscriptions__allowance_status_conflicted" = "Требует внимания"; +"subscriptions__allowance_status_ended" = "Завершён"; +"subscriptions__allowance_paid_automatically" = "{amount} оплачено автоматически"; +"subscriptions__allowance_paid_so_far" = "Оплачено автоматически"; +"subscriptions__allowance_offer_headline" = "{name} предлагает\nлимит автооплаты"; +"subscriptions__allowance_request_headline" = "{name} просит\nлимит автооплаты"; +"subscriptions__allowance_offer_explanation" = "Кошелёк {name} будет оплачивать ваши запросы в пределах этих лимитов без подтверждения каждый раз. Любой из вас может его завершить."; +"subscriptions__allowance_request_explanation" = "Ваш кошелёк будет оплачивать запросы {name} в пределах этих лимитов, не спрашивая вас каждый раз. Любой из вас может его завершить."; +"subscriptions__allowance_accept" = "Принять"; +"subscriptions__allowance_decline" = "Отклонить"; +"subscriptions__allowance_swipe_end" = "Проведите для Завершения"; +"subscriptions__allowance_swipe_withdraw" = "Проведите для Отзыва"; +"subscriptions__allowance_detail_active_allower" = "Запросы в пределах этих лимитов оплачиваются автоматически. Всё, что выше, сначала потребует вашего подтверждения."; +"subscriptions__allowance_detail_active_allowee" = "Ваши запросы в пределах этих лимитов оплачиваются автоматически."; +"subscriptions__allowance_detail_ended" = "Этот лимит автооплаты завершён. Каждый запрос снова требует подтверждения."; +"subscriptions__allowance_error_not_linked" = "Этот контакт ещё не подключён. Повторите попытку через минуту."; +"subscriptions__allowance_error_unavailable" = "Этот лимит автооплаты сейчас недоступен."; +"subscriptions__allowance_payment_in_progress" = "Этот запрос уже оплачивается."; +"subscriptions__allowance_executed_title" = "Платёж Выполнен"; +"subscriptions__allowance_executed_description" = "Автооплата отправила {amount} пользователю {name}"; +"subscriptions__allowance_limit_title" = "Лимит Достигнут"; +"subscriptions__allowance_limit_description" = "Запрос на {amount} от {name} превышает ваш лимит автооплаты. Проверьте его, чтобы оплатить."; +"subscriptions__allowance_auto_paid" = "Оплачено автоматически"; From 3e4c6122fb51960b3c8e699b15a846dc6a4cf27b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 16:17:38 +0200 Subject: [PATCH 39/46] fix: raise limit reached only when a request is over a limit A reservation blocked for another reason, such as an ended allowance or a manual-only decision, sends the request to manual payment without the Limit Reached notification, as on Android. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 17 ++++++++++++++++- .../PaykitAllowanceExecutorTests.swift | 19 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index e1daf32a4..b290110eb 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -411,6 +411,16 @@ actor PaykitAllowanceExecutor { ) } + /// The SDK's block codes for a request over an amount, count or period limit, as on Android. + static let capacityBlockCodes: Set = ["amount_outside_range", "period_amount_limit", "period_count_limit", "lifetime_amount_limit"] + + static func isCapacityBlock(_ reason: Paykit.AllowanceAccountingBlock) -> Bool { + if case let .sharedRule(code) = reason { + return capacityBlockCodes.contains(code) + } + return false + } + /// Whether a covered request is over every limit of the contact's accepted allowances, decided without the node. func exceedsLimits(_ request: PaykitPaymentRequest, allowances: [PaykitAllowance], identity: String) async -> Bool { let covering = allowances.filter { @@ -602,11 +612,16 @@ actor PaykitAllowanceExecutor { checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity)) ) guard case let .ready(prepared) = reservation else { + var isCapacityBlock = false if case let .blocked(reason) = reservation { Logger.info("Allowance reservation blocked: \(reason)", context: "PaykitAllowance") + isCapacityBlock = Self.isCapacityBlock(reason) } _ = try? await sdk.markPaymentManualOnly(occurrence: occurrence) - notifyLimitReached(request, identity: identity) + // Only a limit says Limit Reached; an ended allowance or a manual-only decision just asks. + if isCapacityBlock { + notifyLimitReached(request, identity: identity) + } return .manual } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 6bb2e0e0b..8c645242d 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -285,9 +285,26 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) } + func testReservationBlockedForAReasonOtherThanALimitRaisesNoLimitReached() async throws { + let harness = AllowanceHarness() + await harness.sdk.setAutomaticReservation(.blocked(reason: .manualOnly)) + let events = AllowanceEventRecorder() + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + XCTAssertFalse(events.events.contains { + if case .limitReached = $0 { + true + } else { + false + } + }) + } + func testBlockedReservationMarksThePaymentManualOnly() async throws { let harness = AllowanceHarness() - await harness.sdk.setAutomaticReservation(.blocked(reason: .sharedRule(code: "period_amount_limit_exceeded"))) + await harness.sdk.setAutomaticReservation(.blocked(reason: .sharedRule(code: "period_amount_limit"))) let request = try Fixtures.paymentRequest() let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) From efe4aa2557ac661d10984c8274ce572aa1bcae0b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 16:52:15 +0200 Subject: [PATCH 40/46] fix: keep automatic and manual payments of one request apart No manual request sheet opens while an allowance refresh or pass may still take the request, the limit check ignores expired allowances, and Payment Executed is raised once, from whichever path settles the journal entry, including payments reconciliation settled before recovery. --- Bitkit/AppScene.swift | 5 ++ Bitkit/Services/PaykitAllowanceExecutor.swift | 47 ++++++------ Bitkit/Services/PaykitAllowanceManager.swift | 16 +++++ .../PaykitAllowanceExecutorTests.swift | 71 +++++++++++++++++++ 4 files changed, 114 insertions(+), 25 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5da82f5ca..43ebeb23e 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1600,6 +1600,11 @@ struct AppScene: View { shouldPresentNextRequest = false continue } + // A refresh or pass still running may hand this request to the allowance flow; a later round presents it. + if paykitAllowanceManager.isUpdating, paykitAllowanceManager.mayCover(request) { + shouldPresentNextRequest = false + continue + } let preparation: IncomingPaykitPaymentRequestPreparation if let current = incomingPaymentRequestPreparation, current.ownsSheet(sheets) { guard current.request?.id == request.id else { continue } diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index b290110eb..b4fbd7cd4 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -386,13 +386,14 @@ actor PaykitAllowanceExecutor { } continue } - let wasSettled = localState(identity: identity).journal.first { $0.attemptId == attempt.attemptId }?.stage == .succeeded try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) - // A settlement event already announced a journal entry it settled. - if outcome == .succeeded, !wasSettled { - announceAutomaticPayment(attemptId: attempt.attemptId, identity: identity) + case .succeeded: + // Reconciliation can settle an attempt the journal still shows as sent. + let stage = localState(identity: identity).journal.first { $0.attemptId == attempt.attemptId }?.stage + if let stage, stage != .succeeded { + applyJournalOutcome(attemptId: attempt.attemptId, outcome: .succeeded, identity: identity) } - case .succeeded, .failed: + case .failed: continue } } @@ -401,16 +402,6 @@ actor PaykitAllowanceExecutor { } } - /// Recovery settles payments no settlement event reported, so it raises the same contact link and notification. - private func announceAutomaticPayment(attemptId: String, identity: String) { - guard let entry = localState(identity: identity).journal.first(where: { $0.attemptId == attemptId }), - entry.isAutomatic, let paymentHash = entry.paymentHash - else { return } - Self.eventSubject.send( - .paidAutomatically(counterparty: entry.requestId.counterparty, amountSats: entry.amountSats, paymentId: paymentHash.lowercased()) - ) - } - /// The SDK's block codes for a request over an amount, count or period limit, as on Android. static let capacityBlockCodes: Set = ["amount_outside_range", "period_amount_limit", "period_count_limit", "lifetime_amount_limit"] @@ -424,7 +415,7 @@ actor PaykitAllowanceExecutor { /// Whether a covered request is over every limit of the contact's accepted allowances, decided without the node. func exceedsLimits(_ request: PaykitPaymentRequest, allowances: [PaykitAllowance], identity: String) async -> Bool { let covering = allowances.filter { - $0.isAllower && $0.lifecycleState == .accepted && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) + $0.isAllower && $0.status(at: now()) == .active && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) } guard !covering.isEmpty else { return false } let attempts = await automaticAttempts() @@ -475,8 +466,18 @@ actor PaykitAllowanceExecutor { private func record(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) async throws { try await sdk.recordPaymentOutcome(Paykit.PaymentOutcomeReport(attemptId: attemptId, outcome: outcome)) + applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity) + } + + /// Moves the journal entry to the outcome, and announces an automatic payment only from the call that settles it, + /// so a settlement event and recovery never both raise Payment Executed. + private func applyJournalOutcome(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) { + var settledEntry: PaykitAllowanceLocalState.JournalEntry? updateLocalState(identity: identity) { state in guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + if outcome == .succeeded, state.journal[index].stage != .succeeded { + settledEntry = state.journal[index] + } switch outcome { case .succeeded: state.journal[index].stage = .succeeded case .failed: state.journal[index].stage = .failed @@ -484,6 +485,11 @@ actor PaykitAllowanceExecutor { } } Self.eventSubject.send(.ledgerChanged) + if let entry = settledEntry, entry.isAutomatic, let paymentHash = entry.paymentHash { + Self.eventSubject.send( + .paidAutomatically(counterparty: entry.requestId.counterparty, amountSats: entry.amountSats, paymentId: paymentHash.lowercased()) + ) + } } func automaticAttempts() async -> [PaykitAllowanceCapacity.Attempt] { @@ -751,15 +757,6 @@ actor PaykitAllowanceExecutor { for entry in entries { do { try await record(attemptId: entry.attemptId, outcome: succeeded ? .succeeded : .failed, identity: identity) - if succeeded, entry.isAutomatic { - Self.eventSubject.send( - .paidAutomatically( - counterparty: entry.requestId.counterparty, - amountSats: entry.amountSats, - paymentId: paymentHash.lowercased() - ) - ) - } } catch { Logger.warn("Failed to record an allowance payment outcome: \(error)", context: "PaykitAllowance") } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 42f4205e7..878a8d11e 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -68,6 +68,13 @@ final class PaykitAllowanceManager { @ObservationIgnored private let canPayNow: @MainActor () -> Bool @ObservationIgnored private var identity: String? @ObservationIgnored private var isProcessingRequests = false + @ObservationIgnored private var updateDepth = 0 + + /// Whether allowances are being refreshed or a pass is deciding requests, so a covered request may still change hands. + var isUpdating: Bool { + updateDepth > 0 + } + @ObservationIgnored private var manualRequestIds: [PaykitPaymentRequest.ID: Int] = [:] init( @@ -139,6 +146,8 @@ final class PaykitAllowanceManager { func refresh() async { guard let identity else { return } + updateDepth += 1 + defer { updateDepth -= 1 } do { let records = try await sdk.listAllowances( filter: Paykit.AllowanceFilter(counterparty: nil, localRole: nil, states: []) @@ -284,6 +293,8 @@ final class PaykitAllowanceManager { /// for manual payment, so the caller refreshes before presenting the rest. func processIncomingRequests(_ requests: [PaykitPaymentRequest]) async -> Bool { guard let identity, !isProcessingRequests else { return false } + updateDepth += 1 + defer { updateDepth -= 1 } let signature = allowancesSignature let covered = requests.filter(isAwaitingAutomaticPayment) guard !covered.isEmpty else { return false } @@ -328,6 +339,11 @@ final class PaykitAllowanceManager { return await executor.isHandling(request.id) } + /// Whether this wallet grants, or was asked to grant, the request's contact an allowance in any state. + func mayCover(_ request: PaykitPaymentRequest) -> Bool { + allowances.contains { $0.isAllower && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) } + } + private func isAwaitingAutomaticPayment(_ request: PaykitPaymentRequest) -> Bool { request.requiresAcceptance && request.billingPeriod == nil && coversRequest(request) && manualRequestIds[request.id] != allowancesSignature } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 8c645242d..eacacd1bc 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -197,6 +197,18 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(harness.log.entries.contains("payLightning")) } + @MainActor + func testManagerMayCoverAContactItWasAskedToGrant() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(state: .proposed, terms: Fixtures.standardTerms())]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertTrue(try manager.mayCover(Fixtures.paymentRequest())) + XCTAssertFalse(try manager.mayCover(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey))) + XCTAssertFalse(manager.isUpdating) + } + @MainActor func testManagerKeepsADeferredRequestOffTheSendSheet() async throws { let harness = AllowanceHarness() @@ -537,6 +549,65 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(paid.count, 1) } + func testRecoveryAnnouncesAPaymentReconciliationAlreadySettled() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-settled", attempts: [Fixtures.attemptRecord(id: "settled", status: .succeeded)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "settled", request: request, stage: .sent, paymentHash: paidHash)]), + identity: Fixtures.identityKey + ) + let events = AllowanceEventRecorder() + + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.recover(identity: Fixtures.identityKey) + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + let paid = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: paidHash) } + XCTAssertEqual(paid.count, 1) + } + + func testSettlementAndRecoveryAnnounceOnePaymentOnce() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-sent", attempts: [Fixtures.attemptRecord(id: "sent", status: .submitted)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "sent", request: request, stage: .sent, paymentHash: paidHash)]), + identity: Fixtures.identityKey + ) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))]) + await harness.executor.activate(identity: Fixtures.identityKey) + let events = AllowanceEventRecorder() + + await harness.executor.lightningPaymentSettled(paymentHash: paidHash, succeeded: true) + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.lightningPaymentSettled(paymentHash: paidHash, succeeded: true) + + let paid = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: paidHash) } + XCTAssertEqual(paid.count, 1) + } + + func testLimitCheckIgnoresAnExpiredAllowance() async throws { + let harness = AllowanceHarness() + let active = Fixtures.allowance(allowanceId: "active", perPaymentMaxSats: 500) + let expired = Fixtures.allowance(allowanceId: "expired", perPaymentMaxSats: 5000, expiresAt: Fixtures.now.addingTimeInterval(-60)) + + let exceeds = try await harness.executor.exceedsLimits( + Fixtures.paymentRequest(), + allowances: [active, expired], + identity: Fixtures.identityKey + ) + + XCTAssertTrue(exceeds, "Only an allowance active now can take a request") + } + func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() From 69da1891f652ea9b0d83fbc8eca7799ecf73b07c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 17:00:17 +0200 Subject: [PATCH 41/46] fix: free the payee's payment list when an automatic payment fails The journal keeps the list version an automatic attempt consumed, and recording the attempt failed releases it, after a restart too, so the manual payment that follows can use the same list. Pay on a request the allowance flow holds keeps the requests sheet open, as on Android. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 43 +++++++++++++- .../PrivatePaykitService+Payments.swift | 22 +++++++ .../PaymentRequests/PaymentRequestsView.swift | 12 +++- .../PaykitAllowanceExecutorTests.swift | 58 ++++++++++++++++++- 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index b4fbd7cd4..e40f0ba93 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -79,6 +79,9 @@ struct PaykitAllowanceLocalState: Codable, Equatable { var transactionId: String? var stage: Stage let createdAt: Date + /// The payee's payment list this attempt consumed and the one it replaced, so a failure releases it after a restart. + var paymentListVersion: UInt64? = nil + var previousPaymentListVersion: UInt64? = nil } var groups: [Group] = [] @@ -128,6 +131,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 + /// The list version a consumption replaced, read while its in-memory record still exists. + func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? + /// Rolls back a list a failed automatic payment consumed, when it is still the contact's consumed version. + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async /// 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. /// Returns whether this call added the intent, so a refused acceptance never drops one the manual flow saved. @@ -174,6 +181,18 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { try await PaykitPaymentRequestService().claimForPayment(request) } + func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? { + await PrivatePaykitService.shared.consumedPaymentListVersion(publicKey: publicKey, attemptId: attemptId) + } + + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async { + do { + try await PrivatePaykitService.shared.releaseConsumedPaymentList(publicKey: publicKey, version: version, previousVersion: previousVersion) + } catch { + Logger.warn("Failed to release a payment list a failed automatic payment consumed: \(error)", context: "PaykitAllowance") + } + } + @discardableResult func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool { let store = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) @@ -466,15 +485,26 @@ actor PaykitAllowanceExecutor { private func record(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) async throws { try await sdk.recordPaymentOutcome(Paykit.PaymentOutcomeReport(attemptId: attemptId, outcome: outcome)) - applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity) + let entry = applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity) + // A failed automatic payment leaves the payee's list usable for the manual payment that follows. + if outcome == .failed, let entry, entry.isAutomatic, let version = entry.paymentListVersion { + await payer.releaseConsumedPaymentList( + publicKey: entry.requestId.counterparty, + version: version, + previousVersion: entry.previousPaymentListVersion + ) + } } /// Moves the journal entry to the outcome, and announces an automatic payment only from the call that settles it, /// so a settlement event and recovery never both raise Payment Executed. - private func applyJournalOutcome(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) { + @discardableResult + private func applyJournalOutcome(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) -> PaykitAllowanceLocalState.JournalEntry? { + var entry: PaykitAllowanceLocalState.JournalEntry? var settledEntry: PaykitAllowanceLocalState.JournalEntry? updateLocalState(identity: identity) { state in guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + entry = state.journal[index] if outcome == .succeeded, state.journal[index].stage != .succeeded { settledEntry = state.journal[index] } @@ -490,6 +520,7 @@ actor PaykitAllowanceExecutor { .paidAutomatically(counterparty: entry.requestId.counterparty, amountSats: entry.amountSats, paymentId: paymentHash.lowercased()) ) } + return entry } func automaticAttempts() async -> [PaykitAllowanceCapacity.Attempt] { @@ -670,6 +701,14 @@ actor PaykitAllowanceExecutor { let listAttemptId = UUID() do { try await payer.consumePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: listAttemptId) + if let consumedVersion = payment.context.paymentListVersion { + let previousVersion = await payer.previousPaymentListVersion(publicKey: request.counterparty, attemptId: listAttemptId) + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == submitted.attemptId }) else { return } + state.journal[index].paymentListVersion = consumedVersion + state.journal[index].previousPaymentListVersion = previousVersion + } + } try await payer.ensurePaymentAllowed(request) try await payer.prepareProof( request, diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 98647b48a..6de3653f8 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -346,6 +346,28 @@ extension PrivatePaykitService { ) } + /// Rolls back a list version a failed automatic payment consumed, which a restart may have left without its + /// in-memory consumption record. A newer consumption is left alone. + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) throws { + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.invalidPublicKey + } + guard var contactState = state.contacts[publicKey], contactState.consumedPrivatePaymentListVersion == version else { return } + let previousContactState = state.contacts[publicKey] + contactState.consumedPrivatePaymentListVersion = previousVersion + state.contacts[publicKey] = contactState + do { + try persistStateOrThrow(markWalletBackup: true) + } catch { + state.contacts[publicKey] = previousContactState + throw error + } + Logger.info( + "Released private Paykit payment list version \(version) for \(PubkyPublicKeyFormat.redacted(publicKey)) after a failed automatic payment", + context: "PrivatePaykit" + ) + } + func consumedPaymentListVersion(publicKey: String, attemptId: UUID? = nil) -> UInt64? { guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { return nil } if let attemptId, let consumption = privatePaymentListConsumptions[PrivatePaymentListConsumptionKey(attemptId: attemptId, publicKey: key)] { diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index b79b1c560..8bb1193b1 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -266,6 +266,7 @@ struct PaymentRequestsSheet: View { @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var sheets: SheetViewModel @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + @Environment(PaykitAllowanceManager.self) private var allowances let config: PaymentRequestsSheetItem @@ -319,8 +320,15 @@ struct PaymentRequestsSheet: View { navigation.navigate(.paymentRequestDetail(request.id)) }, onPay: { - sheets.hideSheetBeforePerforming(reason: "Paying payment request") { - _ = paymentRequests.requestPresentation(request) + Task { + // Closing the sheet first would leave nothing on screen behind the toast. + if await allowances.isAutomaticallyHandling(request) { + app.toast(type: .info, title: t("subscriptions__allowance_payment_in_progress")) + return + } + sheets.hideSheetBeforePerforming(reason: "Paying payment request") { + _ = paymentRequests.requestPresentation(request) + } } }, onReject: paymentRequests.canDismiss(request) ? { diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index eacacd1bc..05dd895d6 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -608,6 +608,40 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertTrue(exceeds, "Only an allowance active now can take a request") } + func testFailedAutomaticPaymentReleasesThePaymentListItConsumed() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry( + attemptId: "sent", request: request, stage: .sent, paymentHash: AllowanceHarness.paymentHash, paymentListVersion: 7 + )]), + identity: Fixtures.identityKey + ) + await harness.executor.activate(identity: Fixtures.identityKey) + + await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: false) + + let released = await harness.payer.releasedLists + XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 7, previousVersion: 6)]) + } + + func testRecoveryReleasesThePaymentListOfAnAttemptKilledBeforeSending() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-killed", attempts: [Fixtures.attemptRecord(id: "killed", status: .submitted)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "killed", request: request, stage: .submitted, paymentListVersion: 3)]), + identity: Fixtures.identityKey + ) + + await harness.executor.recover(identity: Fixtures.identityKey) + + let released = await harness.payer.releasedLists + XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 3, previousVersion: 2)]) + } + func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() @@ -1059,7 +1093,8 @@ final class PaykitAllowanceExecutorTests: XCTestCase { attemptId: String, request: PaykitPaymentRequest, stage: PaykitAllowanceLocalState.Stage, - paymentHash: String? = nil + paymentHash: String? = nil, + paymentListVersion: UInt64? = nil ) -> PaykitAllowanceLocalState.JournalEntry { PaykitAllowanceLocalState.JournalEntry( attemptId: attemptId, @@ -1072,7 +1107,9 @@ final class PaykitAllowanceExecutorTests: XCTestCase { onchainAddress: nil, transactionId: nil, stage: stage, - createdAt: Fixtures.now + createdAt: Fixtures.now, + paymentListVersion: paymentListVersion, + previousPaymentListVersion: paymentListVersion.map { $0 - 1 } ) } } @@ -1357,6 +1394,23 @@ private actor AllowancePayerMock: PaykitAllowancePaying { paymentListOutcomes.append(outcome) } + struct ReleasedList: Equatable { + let publicKey: String + let version: UInt64 + let previousVersion: UInt64? + } + + private(set) var releasedLists: [ReleasedList] = [] + + func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? { + nil + } + + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async { + called("releaseConsumedPaymentList") + releasedLists.append(ReleasedList(publicKey: publicKey, version: version, previousVersion: previousVersion)) + } + func claimForExecution(_ request: PaykitPaymentRequest) async throws { called("claimForExecution") if let claimError { From 6f74f1af3eeb08dbdc8cae4379a1e64ed30a6715 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 17:10:05 +0200 Subject: [PATCH 42/46] fix: release the payment list of an attempt reconciliation already failed Recovery moves the journal of attempts the SDK already settled as failed too, and releases the payee's list they consumed, as on Android. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 33 +++++++++++-------- .../PaykitAllowanceExecutorTests.swift | 20 +++++++++++ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index e40f0ba93..57e879e08 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -406,14 +406,16 @@ actor PaykitAllowanceExecutor { continue } try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) - case .succeeded: - // Reconciliation can settle an attempt the journal still shows as sent. + case .succeeded, .failed: + // Reconciliation can settle an attempt the journal still shows as open. + let outcome: Paykit.PaymentOutcome = attempt.status == .succeeded ? .succeeded : .failed let stage = localState(identity: identity).journal.first { $0.attemptId == attempt.attemptId }?.stage - if let stage, stage != .succeeded { - applyJournalOutcome(attemptId: attempt.attemptId, outcome: .succeeded, identity: identity) + if let stage, !stage.isTerminal { + let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity) + if outcome == .failed { + await releaseConsumedPaymentList(of: entry) + } } - case .failed: - continue } } } catch { @@ -486,16 +488,21 @@ actor PaykitAllowanceExecutor { private func record(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) async throws { try await sdk.recordPaymentOutcome(Paykit.PaymentOutcomeReport(attemptId: attemptId, outcome: outcome)) let entry = applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity) - // A failed automatic payment leaves the payee's list usable for the manual payment that follows. - if outcome == .failed, let entry, entry.isAutomatic, let version = entry.paymentListVersion { - await payer.releaseConsumedPaymentList( - publicKey: entry.requestId.counterparty, - version: version, - previousVersion: entry.previousPaymentListVersion - ) + if outcome == .failed { + await releaseConsumedPaymentList(of: entry) } } + /// A failed automatic payment leaves the payee's list usable for the manual payment that follows. + private func releaseConsumedPaymentList(of entry: PaykitAllowanceLocalState.JournalEntry?) async { + guard let entry, entry.isAutomatic, let version = entry.paymentListVersion else { return } + await payer.releaseConsumedPaymentList( + publicKey: entry.requestId.counterparty, + version: version, + previousVersion: entry.previousPaymentListVersion + ) + } + /// Moves the journal entry to the outcome, and announces an automatic payment only from the call that settles it, /// so a settlement event and recovery never both raise Payment Executed. @discardableResult diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 05dd895d6..ba9a63c46 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -642,6 +642,26 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 3, previousVersion: 2)]) } + func testRecoveryReleasesThePaymentListOfAnAttemptReconciliationFailed() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-failed", attempts: [Fixtures.attemptRecord(id: "failed", status: .failed)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "failed", request: request, stage: .sent, paymentListVersion: 5)]), + identity: Fixtures.identityKey + ) + + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.recover(identity: Fixtures.identityKey) + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.failed]) + let released = await harness.payer.releasedLists + XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 5, previousVersion: 4)]) + } + func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() From 98824b6311a3206490a8a5d49fe0c64a5e3d0e4b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 17:31:12 +0200 Subject: [PATCH 43/46] fix: pay bitcoin requests that also price a usdt rail automatically A conversion only prices other rails, and the SDK requires a bitcoin rail to pay the bitcoin amount, so such a request is covered, as on Android. A manual payment made over the USDT rail stays out of the bitcoin ledger. --- Bitkit/Services/PaykitAllowance.swift | 3 ++- Bitkit/Services/PaykitAllowanceExecutor.swift | 3 ++- BitkitTests/PaykitAllowanceExecutorTests.swift | 18 ++++++++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index 09702f8b1..e98636980 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -330,7 +330,8 @@ enum PaykitAllowanceCapacity { extension PaykitPaymentRequest { /// Allowances cover only requests priced in bitcoin without a conversion; any other request always asks. var isAllowancePayable: Bool { - amount.asset == .btc && pricing.conversion == nil + // A conversion only prices other rails; the SDK requires a bitcoin rail to pay the bitcoin amount, as on Android. + amount.asset == .btc } /// The requested amount in sats, read only for a request where `isAllowancePayable` holds. diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 57e879e08..aa2cc4aed 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -814,8 +814,9 @@ 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) async throws -> String? { + // The ledger counts bitcoin only, so a payment made over another rail stays out of it. guard let identity = activeIdentity, request.billingPeriod == nil, request.direction == .incoming, - request.isAllowancePayable + request.isAllowancePayable, PublicPaykitService.MethodId(rawValue: paymentEndpointIdentifier).map({ $0 != .usdtArbitrum }) == true else { return nil } do { try await ensureReconciled(identity: identity) diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index ba9a63c46..8da2093c7 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -956,8 +956,22 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(isHandling, "A USD request reaches the Send sheet instead of waiting for an automatic payment") let convertedRequest = try Fixtures.paymentRequest(conversion: .fixed(rates: [.init(asset: "usdt", value: "100000")])) - XCTAssertFalse(convertedRequest.isAllowancePayable, "A bitcoin request that offers a conversion stays manual") - XCTAssertFalse(manager.coversRequest(convertedRequest)) + XCTAssertTrue(convertedRequest.isAllowancePayable, "A bitcoin request that also prices a USDT rail pays over bitcoin") + XCTAssertTrue(manager.coversRequest(convertedRequest)) + } + + func testManualPaymentOverTheUsdtRailStaysOutOfTheLedger() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest(conversion: .fixed(rates: [.init(asset: "usdt", value: "100000")])) + + let attemptId = try await harness.executor.beginManualPayment( + request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.usdtArbitrum.rawValue + ) + + XCTAssertNil(attemptId) + XCTAssertFalse(harness.log.entries.contains("reserveManualPayment")) } @MainActor From 723cdf564dfe68160c149251e18819b8c2f0dd25 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 17:40:56 +0200 Subject: [PATCH 44/46] fix: present a held request as soon as allowances settle A request held back while allowances refresh is presented as soon as the update ends instead of at the next poll, only an allowance that could start covering the contact holds a request back, and recovery reads the journal once. --- Bitkit/AppScene.swift | 6 ++++++ Bitkit/Services/PaykitAllowanceExecutor.swift | 8 ++++++-- Bitkit/Services/PaykitAllowanceManager.swift | 9 ++++++--- BitkitTests/PaykitAllowanceExecutorTests.swift | 10 ++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 43ebeb23e..3ae91a344 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -713,6 +713,12 @@ struct AppScene: View { Task { await presentNextIncomingPaykitItem() } } } + .onChange(of: paykitAllowanceManager.isUpdating) { _, isUpdating in + // A request held back while allowances were updating is presented as soon as they settle. + if !isUpdating { + Task { await presentNextIncomingPaykitItem() } + } + } .onChange(of: incomingPaykitPaymentRequestPresentationState) { previous, current in handleIncomingPaykitPaymentRequestPresentationStateChange(from: previous, to: current) } diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index aa2cc4aed..b35c1e820 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -393,6 +393,11 @@ actor PaykitAllowanceExecutor { // 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) + // One journal read for the whole pass: each attempt appears once, so a stage changed below is never read again. + let journalStages = Dictionary( + localState(identity: identity).journal.map { ($0.attemptId, $0.stage) }, + uniquingKeysWith: { first, _ in first } + ) for attempt in state.history.occurrences.flatMap(\.attempts) { switch attempt.status { case .prepared: @@ -409,8 +414,7 @@ actor PaykitAllowanceExecutor { case .succeeded, .failed: // Reconciliation can settle an attempt the journal still shows as open. let outcome: Paykit.PaymentOutcome = attempt.status == .succeeded ? .succeeded : .failed - let stage = localState(identity: identity).journal.first { $0.attemptId == attempt.attemptId }?.stage - if let stage, !stage.isTerminal { + if let stage = journalStages[attempt.attemptId], !stage.isTerminal { let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity) if outcome == .failed { await releaseConsumedPaymentList(of: entry) diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 878a8d11e..e920538d8 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -68,7 +68,7 @@ final class PaykitAllowanceManager { @ObservationIgnored private let canPayNow: @MainActor () -> Bool @ObservationIgnored private var identity: String? @ObservationIgnored private var isProcessingRequests = false - @ObservationIgnored private var updateDepth = 0 + private var updateDepth = 0 /// Whether allowances are being refreshed or a pass is deciding requests, so a covered request may still change hands. var isUpdating: Bool { @@ -339,9 +339,12 @@ final class PaykitAllowanceManager { return await executor.isHandling(request.id) } - /// Whether this wallet grants, or was asked to grant, the request's contact an allowance in any state. + /// Whether an allowance this wallet grants, or was asked to grant, could start covering the request's contact. func mayCover(_ request: PaykitPaymentRequest) -> Bool { - allowances.contains { $0.isAllower && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) } + allowances.contains { + $0.isAllower && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && + ($0.lifecycleState == .proposed || $0.status(at: now()) == .active) + } } private func isAwaitingAutomaticPayment(_ request: PaykitPaymentRequest) -> Bool { diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 8da2093c7..c2a7746bc 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -209,6 +209,16 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(manager.isUpdating) } + @MainActor + func testManagerMayNotCoverAContactWhoseAllowanceEnded() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(state: .ended, terms: Fixtures.standardTerms())]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertFalse(try manager.mayCover(Fixtures.paymentRequest()), "An ended allowance can't start covering a request") + } + @MainActor func testManagerKeepsADeferredRequestOffTheSendSheet() async throws { let harness = AllowanceHarness() From 4a1e3917e8b8461646b815af4582a366b246d329 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 17:44:34 +0200 Subject: [PATCH 45/46] fix: release a consumed payment list only once A list released when an automatic payment fails before broadcast is cleared from its journal entry first, so recording the attempt failed does not release it again after another payment consumed the same version. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 34 ++++++++++++++++--- .../PaykitAllowanceExecutorTests.swift | 14 ++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index b35c1e820..ee406044e 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -729,7 +729,10 @@ actor PaykitAllowanceExecutor { ) } catch { await payer.cancelProofPreparation(request) - await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: submitted.attemptId, identity: identity, + outcome: .definitePreBroadcastFailure + ) try await record(attemptId: submitted.attemptId, outcome: .failed, identity: identity) throw error } @@ -748,8 +751,19 @@ actor PaykitAllowanceExecutor { _ request: PaykitPaymentRequest, payment: PrivatePaykitAllowancePayment, attemptId: UUID, + journalAttemptId: String, + identity: String, outcome: PrivatePaymentListSendOutcome ) async { + if outcome == .definitePreBroadcastFailure { + // Released here, so recording the attempt failed must not release it again: by then another payment may + // have consumed the same list version. + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == journalAttemptId }) else { return } + state.journal[index].paymentListVersion = nil + state.journal[index].previousPaymentListVersion = nil + } + } await payer.resolvePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: attemptId, outcome: outcome) } @@ -766,7 +780,10 @@ actor PaykitAllowanceExecutor { try await payer.associateLightningPayment(request, paymentHash: paymentHash) } catch { await payer.cancelProofPreparation(request) - await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, + outcome: .definitePreBroadcastFailure + ) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } @@ -781,17 +798,24 @@ actor PaykitAllowanceExecutor { guard await payer.failLightningPayment(paymentHash: paymentHash, submissionError: error) else { // The node may have started the payment; its settlement event records the outcome. Logger.warn("Automatic Lightning allowance payment outcome is uncertain: \(error)", context: "PaykitAllowance") - await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, outcome: .uncertain + ) if !hasTerminalStage(attemptId: attemptId, identity: identity) { try await record(attemptId: attemptId, outcome: .unknown, identity: identity) } return .started } - await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, + outcome: .definitePreBroadcastFailure + ) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } - await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, outcome: .uncertain + ) setStage(.sent, attemptId: attemptId, identity: identity) Logger.info("Handed an allowance payment to the node", context: "PaykitAllowance") return .started diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index c2a7746bc..2ce7f163b 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -618,6 +618,20 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertTrue(exceeds, "Only an allowance active now can take a request") } + func testPaymentListReleasedBeforeBroadcastIsNeverReleasedAgain() async throws { + let harness = AllowanceHarness() + await harness.payer.setLightningError(AllowanceMockError.unsupported) + + _ = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) + let released = await harness.payer.releasedLists + XCTAssertEqual(released, [], "A second release could clear another payment's consumption of the same version") + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.paymentListVersion), [nil]) + } + func testFailedAutomaticPaymentReleasesThePaymentListItConsumed() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() From d500fab9f7bbaa0730d0cd11c0a3ed2f9a191ab9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 18:09:54 +0200 Subject: [PATCH 46/46] fix: never roll back a payment list a settlement already released Recovery's cached journal stages only skip settled attempts; moving an attempt to failed checks its current stage in the same journal update, so a settlement that ran while recovery waited is not released twice. The allowance payability comment matches the conversion change. --- Bitkit/Services/PaykitAllowance.swift | 4 +- Bitkit/Services/PaykitAllowanceExecutor.swift | 23 ++++++---- .../PaykitAllowanceExecutorTests.swift | 42 +++++++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index e98636980..aece2ece2 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -328,9 +328,9 @@ enum PaykitAllowanceCapacity { } extension PaykitPaymentRequest { - /// Allowances cover only requests priced in bitcoin without a conversion; any other request always asks. + /// Allowances cover only requests priced in bitcoin, paid over a bitcoin rail. A conversion only prices other rails, and + /// the SDK requires a bitcoin rail to pay the bitcoin amount, so it does not keep a request manual. var isAllowancePayable: Bool { - // A conversion only prices other rails; the SDK requires a bitcoin rail to pay the bitcoin amount, as on Android. amount.asset == .btc } diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index ee406044e..812f41938 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -414,11 +414,13 @@ actor PaykitAllowanceExecutor { case .succeeded, .failed: // Reconciliation can settle an attempt the journal still shows as open. let outcome: Paykit.PaymentOutcome = attempt.status == .succeeded ? .succeeded : .failed - if let stage = journalStages[attempt.attemptId], !stage.isTerminal { - let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity) - if outcome == .failed { - await releaseConsumedPaymentList(of: entry) - } + // The cached stage only skips settled attempts cheaply; a settlement while this loop awaited may have + // settled one since, so the move itself checks the current stage. + if let stage = journalStages[attempt.attemptId], !stage.isTerminal, + let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity, onlyIfOpen: true), + outcome == .failed + { + await releaseConsumedPaymentList(of: entry) } } } @@ -510,11 +512,18 @@ actor PaykitAllowanceExecutor { /// Moves the journal entry to the outcome, and announces an automatic payment only from the call that settles it, /// so a settlement event and recovery never both raise Payment Executed. @discardableResult - private func applyJournalOutcome(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) -> PaykitAllowanceLocalState.JournalEntry? { + private func applyJournalOutcome( + attemptId: String, + outcome: Paykit.PaymentOutcome, + identity: String, + onlyIfOpen: Bool = false + ) -> PaykitAllowanceLocalState.JournalEntry? { var entry: PaykitAllowanceLocalState.JournalEntry? var settledEntry: PaykitAllowanceLocalState.JournalEntry? updateLocalState(identity: identity) { state in - guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }), + !onlyIfOpen || !state.journal[index].stage.isTerminal + else { return } entry = state.journal[index] if outcome == .succeeded, state.journal[index].stage != .succeeded { settledEntry = state.journal[index] diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 2ce7f163b..e9a74bc3e 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -686,6 +686,35 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 5, previousVersion: 4)]) } + func testRecoveryNeverReleasesAListASettlementReleasedWhileItWaited() async throws { + let harness = AllowanceHarness() + let first = try Fixtures.paymentRequest() + let second = try Fixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440002") + let secondHash = String(repeating: "2", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-a", attempts: [Fixtures.attemptRecord(id: "a", status: .failed)]), + Fixtures.occurrence(requestId: "req-b", attempts: [Fixtures.attemptRecord(id: "b", status: .failed)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [ + Self.journalEntry(attemptId: "a", request: first, stage: .sent, paymentListVersion: 5), + Self.journalEntry(attemptId: "b", request: second, stage: .sent, paymentHash: secondHash, paymentListVersion: 7), + ]), + identity: Fixtures.identityKey + ) + await harness.executor.activate(identity: Fixtures.identityKey) + let gate = await harness.payer.holdNextRelease() + + let recovery = Task { await harness.executor.recover(identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("releaseConsumedPaymentList") } + await harness.executor.lightningPaymentSettled(paymentHash: secondHash, succeeded: false) + gate.open() + await recovery.value + + let released = await harness.payer.releasedLists.map(\.version) + XCTAssertEqual(released.filter { $0 == 7 }.count, 1, "The second attempt's list is released once, by the settlement") + } + func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() @@ -1464,9 +1493,22 @@ private actor AllowancePayerMock: PaykitAllowancePaying { nil } + private var releaseGate: AllowanceGate? + + /// Holds the next list release until the returned gate opens. + func holdNextRelease() -> AllowanceGate { + let gate = AllowanceGate() + releaseGate = gate + return gate + } + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async { called("releaseConsumedPaymentList") releasedLists.append(ReleasedList(publicKey: publicKey, version: version, previousVersion: previousVersion)) + if let gate = releaseGate { + releaseGate = nil + await gate.wait() + } } func claimForExecution(_ request: PaykitPaymentRequest) async throws {