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