Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2de9fac
feat: add paykit allowance admission and auto-pay executor
ovitrif Sep 24, 2026
621fcda
feat: add allowances tab, set allowance sheet and auto-pay wiring
ovitrif Sep 24, 2026
6eafd42
fix: attribute auto-paid activity and round allowance estimates
ovitrif Sep 24, 2026
f1291e1
fix: keep pre-allowance requests manual and log allowance decisions
ovitrif Sep 24, 2026
8d04d1f
test: cover allowance admission, limits and restart recovery
ovitrif Sep 24, 2026
44ae954
fix: count allowance months from the original anchor
ovitrif Sep 24, 2026
460e7ad
test: pass the proof allowance id in existing paykit fixtures
ovitrif Sep 24, 2026
c725de9
fix: leave wallets without an allowance ledger untouched at launch
ovitrif Sep 24, 2026
d577b84
fix: hold automatic payments until the payee's next list and usable c…
ovitrif Sep 24, 2026
80dde8e
docs: add the allowance journeys
ovitrif Sep 24, 2026
b806944
chore: add changelog fragment
ovitrif Sep 24, 2026
dbe7fd4
fix: adapt allowances to the shared paykit identity
ovitrif Oct 1, 2026
87d7996
fix: finish an allowance payment once it starts
ovitrif Oct 1, 2026
a593f0e
fix: keep an auto-accepted request recoverable as a manual payment
ovitrif Oct 1, 2026
8f0449c
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 1, 2026
8cac27f
fix: keep a covered request off the send sheet until its automatic pa…
ovitrif Oct 2, 2026
1cde24a
fix: stop the request presentation from spinning on a request the all…
ovitrif Oct 2, 2026
a19ea87
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
fcc9e86
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
9993a22
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
0f4ec4e
test: pin that allowance windows ignore the subscription clock offset
ovitrif Oct 2, 2026
af2e92c
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
33dcf4b
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
cdb664e
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
6658b0f
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
7eda1ec
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
6a97572
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
e53eaae
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
32d00d7
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
bb6b941
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
8eac259
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
fe1ef54
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
20bb4d5
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
426403f
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
2073233
Merge remote-tracking branch 'origin/codex/paykit-shared-runtime-loca…
ovitrif Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ struct AppScene: View {
@State private var receivedPaymentBackfillCache = PaykitReceivedPaymentBackfillCache(
activityChanges: CoreService.shared.activity.activitiesChangedPublisher
)
@State private var paykitAllowanceManager = PaykitAllowanceManager()

@State private var hideSplash = false
@State private var removeSplash = false
Expand Down Expand Up @@ -434,6 +435,7 @@ struct AppScene: View {
.environment(hwWalletManager)
.environment(calculatorInputManager)
.environment(paykitPaymentRequestManager)
.environment(paykitAllowanceManager)
}

private var appEventContent: some View {
Expand All @@ -443,6 +445,7 @@ struct AppScene: View {
if authState == .authenticated, let pk = pubkyProfile.publicKey {
paykitPaymentRequestManager.activate(identity: pk)
Task {
await paykitAllowanceManager.activate(identity: pk)
await handlePendingPaykitSubscriptionNotification()
try? await contactsManager.loadContacts(for: pk)
await refreshPrivateOnlyPaykitApp()
Expand All @@ -457,6 +460,7 @@ struct AppScene: View {
} else if authState == .idle {
contactsManager.reset()
paykitPaymentRequestManager.clear()
paykitAllowanceManager.deactivate()
}
}
.onReceive(contactsManager.$contacts.map { $0.map(\.publicKey).sorted() }.removeDuplicates()) { publicKeys in
Expand All @@ -472,6 +476,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) }
}
Expand Down Expand Up @@ -1118,6 +1125,12 @@ struct AppScene: View {
}
guard let identity = pubkyProfile.publicKey else { return }
await paykitPaymentRequestManager.refresh(syncPrivateMessages: refreshMaintenance)
await paykitAllowanceManager.refresh()
let handledAutomatically = await paykitAllowanceManager.processIncomingRequests(paykitPaymentRequestManager.pendingRequests)
let adoptedAcceptances = paykitPaymentRequestManager.reloadAcceptedRequestIds()
if handledAutomatically || adoptedAcceptances {
await paykitPaymentRequestManager.refresh(syncPrivateMessages: false)
}
guard pubkyProfile.authState == .authenticated,
PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey)
else { return }
Expand Down Expand Up @@ -1217,6 +1230,11 @@ struct AppScene: View {
guard sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return }
for request in requests {
guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return }
if await paykitAllowanceManager.isAutomaticallyHandling(request) {
// The next refresh presents it if the allowance flow hands it back; presenting again now would spin on it.
shouldPresentNextRequest = false
continue
}
do {
let result = try await PrivatePaykitService.shared.beginPaymentRequest(request)
guard paykitPaymentRequestManager.isCurrentPresentation(request),
Expand Down Expand Up @@ -1453,6 +1471,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 scenePhase == .active,
isPinVerified || !settings.pinEnabled,
Expand All @@ -1470,6 +1526,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()
}

Expand Down
47 changes: 47 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1755,3 +1755,50 @@
"settings__adv__pp_both" = "Both";
"settings__adv__pp_lightning_short" = "Lightning";
"settings__adv__pp_onchain_short" = "On-chain";
"subscriptions__allowances" = "Allowances";
"subscriptions__allowances_empty_headline" = "Set up\n<accent>allowances</accent>";
"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\n<accent>an allowance</accent>";
"subscriptions__allowance_request_headline" = "{name} asks for\n<accent>an allowance</accent>";
"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";
Loading
Loading