Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
d5d9fc3
feat: saturating stup method
jvsena42 Aug 26, 2026
2549ff8
feat: budget loop logic
jvsena42 Aug 26, 2026
5388c5f
fea: make UI update to budget gates
jvsena42 Aug 26, 2026
e6af5b2
test: transfer budget tests
jvsena42 Aug 26, 2026
ccab5e3
fix: clamp entered capacity to settled max:
jvsena42 Aug 26, 2026
760eba7
fix: drop number pad opacity
jvsena42 Aug 26, 2026
546e57b
Merge branch 'master' into fix/spending-transfer-affordability
jvsena42 Sep 2, 2026
f9f728b
test: cover settled transfer maximums in journeys
jvsena42 Sep 2, 2026
31d7770
Merge branch 'master' into fix/spending-transfer-affordability
jvsena42 Sep 2, 2026
4c48d6b
fix: cap the hardware transfer amount input
jvsena42 Sep 2, 2026
0536fc0
Merge branch 'fix/spending-transfer-affordability' of github.com:syno…
jvsena42 Sep 2, 2026
e95d049
chore: trim the transfer affordability comments
jvsena42 Sep 2, 2026
887b33c
Merge branch 'master' into fix/spending-transfer-affordability
jvsena42 Sep 4, 2026
a0c44b9
fix: make the iOS budget read as cheap as Android's
jvsena42 Sep 4, 2026
5386569
feat: upgrade paykit auth to rc50
ben-kaufman Aug 31, 2026
c1a03c4
chore: rename changelog fragment
ben-kaufman Aug 31, 2026
586fce5
fix: reconcile paykit auth failures
ben-kaufman Aug 31, 2026
f32b56f
test: cover private endpoint reconciliation
ben-kaufman Sep 1, 2026
b62fedf
test: use grant auth fixtures
ben-kaufman Sep 1, 2026
0e0725a
fix: approve external Pubky grants
ben-kaufman Sep 2, 2026
4befe20
fix: harden Pubky session recovery
ben-kaufman Sep 2, 2026
d383646
test: cover external auth client ID
ben-kaufman Sep 2, 2026
c23748a
fix: bound auth requester label
ben-kaufman Sep 3, 2026
35816dd
fix: clear abandoned paykit sessions
ben-kaufman Sep 3, 2026
d90a6a9
fix: harden Paykit session recovery
ben-kaufman Sep 4, 2026
2777d79
fix: reset forgotten Paykit sessions
ben-kaufman Sep 4, 2026
33636d7
feat: add Paykit subscription payments
ben-kaufman Aug 26, 2026
9ad0634
chore: name subscription changelog fragment
ben-kaufman Aug 26, 2026
563c68c
fix: polish subscription payments
ben-kaufman Aug 27, 2026
ff5aac4
fix: preserve subscription details flow
ben-kaufman Sep 1, 2026
6837029
fix: reconcile subscription stack
ben-kaufman Sep 1, 2026
1b8d3bf
fix: support hardware subscription payments
ben-kaufman Sep 1, 2026
49436fc
fix: avoid duplicate payment handlers
ben-kaufman Sep 1, 2026
1d4b816
fix: track hardware subscription payments
ben-kaufman Sep 2, 2026
2a5254d
fix: clear failed hardware proofs
ben-kaufman Sep 2, 2026
7a70b30
test: synchronize onchain proof reconciliation
ben-kaufman Sep 2, 2026
a79bf4d
fix: show normalized monthly cost
ben-kaufman Sep 3, 2026
432b7e5
refactor: remove unused acceptance accessor
ben-kaufman Sep 3, 2026
7c01303
fix: harden Paykit subscription recovery
ben-kaufman Sep 4, 2026
9136de4
fix: harden subscription edge cases
ben-kaufman Sep 4, 2026
cad5f36
Merge branch 'master' into fix/spending-transfer-affordability
jvsena42 Sep 4, 2026
8338720
fix: harden initial subscription payments
ben-kaufman Sep 4, 2026
57dc1cc
fix: preserve payment request history
ben-kaufman Sep 4, 2026
62ee2f0
fix: preserve uncertain lightning payments
ben-kaufman Sep 4, 2026
748aaf0
fix: preserve private paykit receiver marker
ben-kaufman Sep 6, 2026
39fc5cc
fix: distinguish pending paykit request ids
ben-kaufman Sep 6, 2026
3447208
fix: scope auth recovery to its completed session
ben-kaufman Sep 7, 2026
92376ab
fix: distinguish pre-broadcast subscription failures
ben-kaufman Sep 7, 2026
559e300
fix: confirm subscription funding changes
ben-kaufman Sep 7, 2026
70c51fa
refactor: remove paykit migration handling
ben-kaufman Sep 7, 2026
2834e7a
feat: support Pubky Ring signup
ben-kaufman Sep 2, 2026
027f28a
fix: complete Pubky Ring signup
ben-kaufman Sep 2, 2026
86e8068
feat: support direct Pubky signup
ben-kaufman Sep 2, 2026
00d8cae
fix: complete Pubky signup handoff
ben-kaufman Sep 3, 2026
1dcc1ce
fix: recover failed pubky signup
ben-kaufman Sep 3, 2026
f1a6adf
fix: recover and verify pubky signup state
ben-kaufman Sep 6, 2026
9320e11
fix: require consent for every pubky signup
ben-kaufman Sep 7, 2026
951c102
fix: preserve pubky homeserver on restore
ben-kaufman Sep 7, 2026
6646af9
refactor: simplify pubky profile setup wiring
ben-kaufman Sep 8, 2026
ae4b584
feat: open Pubky auth links
ovitrif Sep 2, 2026
9dac17b
fix: preserve Pubky Ring handoff
ovitrif Sep 2, 2026
d24cb40
chore: rename changelog fragment
ovitrif Sep 2, 2026
6ce8c65
fix: retain deep links through startup
ovitrif Sep 2, 2026
816ad38
fix: target Bitkit Pubky auth links uniquely
ovitrif Sep 2, 2026
b1bd510
fix: reject malformed Pubky handoff fragments
ovitrif Sep 2, 2026
d78e3d1
fix: preserve Pubky wrapper validation
ovitrif Sep 2, 2026
2687f89
fix: ungated Pubky links; reject dupes
ovitrif Sep 2, 2026
a801819
fix: classify setup routes before node
ovitrif Sep 3, 2026
4b07f97
fix: ungated BIP21 and BOLT11 links
ovitrif Sep 3, 2026
69025cd
fix: route gifts and identify auth relay
ovitrif Sep 5, 2026
02eb8d4
fix: retain scene-delivered deep links
ovitrif Sep 5, 2026
8925916
fix: forward app-delivered deep links
ovitrif Sep 5, 2026
4d0a0b6
chore: format deep-link files
ovitrif Sep 8, 2026
dc243ca
fix: show both units in payment request details
ben-kaufman Sep 8, 2026
1588db1
Merge branch 'master' into fix/spending-transfer-affordability
jvsena42 Sep 8, 2026
bcc0725
feat: stack marketplace auth on signup
ovitrif Sep 8, 2026
3470fd5
fix: make budget closure async
jvsena42 Sep 8, 2026
c2de3f0
docs: clarify pubky link contract
ovitrif Sep 8, 2026
420004d
fix: preserve Paykit child accessibility identifiers
ben-kaufman Sep 9, 2026
df49bd0
Merge branch 'master' into codex/paykit-subscriptions
ben-kaufman Sep 9, 2026
0cb171c
Merge pull request #686 from synonymdev/fix/spending-transfer-afforda…
jvsena42 Sep 9, 2026
692930a
Merge pull request #685 from synonymdev/codex/paykit-subscriptions
ovitrif Sep 9, 2026
7a82848
fix(receive): handle additional receive liquidity edge cases (#711)
pwltr Sep 9, 2026
0ec6e49
merge: refresh Pubky signup stack
ovitrif Sep 9, 2026
30cc049
merge: refresh master
ovitrif Sep 9, 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
142 changes: 135 additions & 7 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ struct AppScene: View {
}

var body: some View {
appEventContent
}

private var configuredContent: some View {
mainContent
.sheet(
item: $sheets.forgotPinSheetItem,
Expand All @@ -144,11 +148,13 @@ struct AppScene: View {
.task(priority: .userInitiated, setupTask)
.task(id: scenePhase) { await pollIncomingPaykitPaymentRequests() }
.task(id: initialPaykitSyncGeneration) { await pollIncomingPaykitPaymentRequestsDuringInitialSync() }
.task { await handlePendingPaykitSubscriptionNotification() }
.onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) }
.onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) }
.onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) }
.onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) }
.onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) }
.onOpenURL { url in app.retainDeepLink(url) }
// Bridge Trezor device state into the watch-only manager without coupling the two:
// TrezorManager bumps devicesRevision on any device/connection change.
.onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() }
Expand Down Expand Up @@ -205,13 +211,21 @@ struct AppScene: View {
.environment(hwWalletManager)
.environment(calculatorInputManager)
.environment(paykitPaymentRequestManager)
}

private var appEventContent: some View {
configuredContent
.onChange(of: pubkyProfile.authState, initial: true) { _, authState in
if authState == .authenticated, let pk = pubkyProfile.publicKey {
paykitPaymentRequestManager.activate(identity: pk)
Task {
try? await contactsManager.loadContacts(for: pk)
await refreshPrivateOnlyPaykitReceiverMarker()
await refreshIncomingPaykitPaymentRequests()
await refreshIncomingPaykitPaymentRequests(presentItems: false)
await handlePendingPaykitSubscriptionNotification()
if PaykitSubscriptionNotificationTargetStore.load() == nil {
await presentNextIncomingPaykitItem()
}
if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true {
await retryPendingPaykitEndpointRemoval()
}
Expand Down Expand Up @@ -241,6 +255,15 @@ struct AppScene: View {
.onReceive(PrivatePaykitService.initialLinkBurstStartedPublisher) {
initialPaykitSyncGeneration += 1
}
.onReceive(PaykitPaymentProofService.proofStateChangedPublisher) {
Task { await refreshIncomingPaykitPaymentRequests() }
}
.onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in
Task { await associateResolvedPaykitOnchainPayment(resolution) }
}
.onReceive(NotificationCenter.default.publisher(for: .paykitSubscriptionPaymentDue)) { _ in
Task { await handlePendingPaykitSubscriptionNotification() }
}
.onChange(of: sheets.activeSheetConfiguration?.id) { _, activeSheetId in
guard activeSheetId == nil, !sheets.isReplacingSheet else { return }
Task {
Expand All @@ -249,7 +272,7 @@ struct AppScene: View {
sheets.activeSheetConfiguration == nil,
!sheets.isReplacingSheet
else { return }
await presentNextIncomingPaykitPaymentRequest()
await presentNextIncomingPaykitItem()
}
}
.onChange(of: paykitPaymentRequestManager.requestedPresentationId) { _, requestId in
Expand All @@ -262,11 +285,15 @@ struct AppScene: View {
.onChange(of: paykitPaymentRequestManager.pendingRequests) { _, requests in
guard let request = app.contactPaymentContext?.incomingPaymentRequest,
!requests.contains(where: { $0.id == request.id }),
!paykitPaymentRequestManager.isApprovedForPayment(request),
sheets.activeSheetConfiguration?.id == .send
!paykitPaymentRequestManager.isApprovedForPayment(request)
else { return }

let activeSheetId = sheets.activeSheetConfiguration?.id
guard activeSheetId == .send ||
(activeSheetId == .subscription && app.contactPaymentContext?.isInitialSubscriptionPayment == true)
else { return }

sheets.hideSheetIfActive(.send, reason: "Incoming payment request is no longer available")
sheets.hideSheet(reason: "Incoming payment request is no longer available")
}
.onChange(of: navigation.currentRoute) { oldRoute, newRoute in
guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else {
Expand All @@ -286,6 +313,10 @@ struct AppScene: View {
isPinVerified = true
}

if let url = DeepLinkRouter.shared.consume() {
app.retainDeepLink(url)
}

// Listen for quick action notifications
NotificationCenter.default.addObserver(
forName: .quickActionSelected,
Expand All @@ -294,6 +325,13 @@ struct AppScene: View {
) { notification in
handleQuickAction(notification)
}
NotificationCenter.default.addObserver(
forName: .deepLinkReceived,
object: nil,
queue: .main
) { notification in
handleDeepLinkNotification(notification)
}
}
.onReceive(BackupService.shared.backupFailurePublisher) { intervalMinutes in
handleBackupFailure(intervalMinutes: intervalMinutes)
Expand All @@ -304,6 +342,16 @@ struct AppScene: View {
}
}

private func handleDeepLinkNotification(_ notification: Notification) {
if let retainedURL = DeepLinkRouter.shared.consume() {
app.retainDeepLink(retainedURL)
return
}
if let receivedURL = notification.object as? URL {
app.retainDeepLink(receivedURL)
}
}

private var mainContent: some View {
ZStack {
if Env.isTrezorEmulatorTesting {
Expand Down Expand Up @@ -767,7 +815,7 @@ struct AppScene: View {
}

@discardableResult
private func refreshIncomingPaykitPaymentRequests() async -> Bool {
private func refreshIncomingPaykitPaymentRequests(presentItems: Bool = true) async -> Bool {
guard PaykitFeatureFlags.isUIEnabled,
wallet.walletExists == true,
pubkyProfile.authState == .authenticated
Expand All @@ -780,10 +828,40 @@ struct AppScene: View {
let previousRequests = paykitPaymentRequestManager.pendingRequests
await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey))
await paykitPaymentRequestManager.refresh()
await presentNextIncomingPaykitPaymentRequest()
if presentItems {
await presentNextIncomingPaykitItem()
}
return paykitPaymentRequestManager.pendingRequests != previousRequests
}

private func associateResolvedPaykitOnchainPayment(_ resolution: PaykitOnchainPaymentResolution) async {
if let identity = pubkyProfile.publicKey,
PubkyPublicKeyFormat.matches(resolution.identity, identity)
{
do {
_ = try await tryNTimes(
toTry: {
try? await activity.syncLdkNodePayments()
return try await activity.findActivity(byPaymentId: resolution.transactionId)
},
times: 12,
interval: 2
)
try await activity.setContact(
resolution.requestId.counterparty,
forPaymentId: resolution.transactionId,
syncLdkPayments: false
)
} catch {
Logger.warn(
"Failed to associate resolved Paykit payment \(resolution.transactionId) with its contact: \(error)",
context: "AppScene"
)
}
}
await PaykitPaymentProofService.shared.consumeOnchainPaymentResolution(resolution)
}

private func pollIncomingPaykitPaymentRequests() async {
guard scenePhase == .active else { return }

Expand Down Expand Up @@ -954,6 +1032,56 @@ struct AppScene: View {
await presentNextIncomingPaykitPaymentRequest()
}

private func handlePendingPaykitSubscriptionNotification() async {
guard let target = PaykitSubscriptionNotificationTargetStore.load() else { return }
guard let identity = pubkyProfile.publicKey else { return }
guard target.matches(identity: identity) else {
PaykitSubscriptionNotificationTargetStore.clear()
return
}
guard sheets.activeSheetConfiguration == nil,
!sheets.isReplacingSheet,
app.contactPaymentContext == nil
else { return }
await refreshIncomingPaykitPaymentRequests(presentItems: false)
guard let request = paykitPaymentRequestManager.pendingRequests.first(where: target.matches) else {
if paykitPaymentRequestManager.historyRequests.contains(where: target.matches) {
PaykitSubscriptionNotificationTargetStore.clear()
} else if paykitPaymentRequestManager.hasDismissedSubscriptionPayment(matching: target) {
PaykitSubscriptionNotificationTargetStore.clear()
} else if !paykitPaymentRequestManager.subscriptions.contains(where: {
$0.paymentRequestId == target.paymentRequestId &&
PubkyPublicKeyFormat.matches($0.counterparty, target.counterparty) &&
$0.counterpartyReceiverPath == target.counterpartyReceiverPath &&
$0.isActive(at: Date())
}) {
PaykitSubscriptionNotificationTargetStore.clear()
}
return
}

if paykitPaymentRequestManager.requestedPresentationId != request.id {
guard paykitPaymentRequestManager.requestPresentation(request) else { return }
}
await presentNextIncomingPaykitPaymentRequest()
}

private func presentNextIncomingPaykitItem() async {
guard sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet else { return }
if PaykitSubscriptionNotificationTargetStore.load() != nil {
await handlePendingPaykitSubscriptionNotification()
guard PaykitSubscriptionNotificationTargetStore.load() == nil,
sheets.activeSheetConfiguration == nil,
!sheets.isReplacingSheet
else { return }
}
if let subscription = paykitPaymentRequestManager.subscriptionProposalForPresentation() {
sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription)))
return
}
await presentNextIncomingPaykitPaymentRequest()
}

private func retryPendingPaykitEndpointRemoval() async {
if PublicPaykitService.isCleanupPending {
do {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "subscription-clock.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 82 additions & 1 deletion Bitkit/BitkitApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,71 @@ import SwiftUI
/// Communication bridge between delegates and SwiftUI views
extension Notification.Name {
static let quickActionSelected = Notification.Name("quickActionSelected")
static let deepLinkReceived = Notification.Name("deepLinkReceived")
static let paykitSubscriptionPaymentDue = Notification.Name("paykitSubscriptionPaymentDue")
}

struct PaykitSubscriptionNotificationTarget: Codable, Equatable {
let payerIdentity: String
let paymentRequestId: String
let counterparty: String
let counterpartyReceiverPath: String
let billingPeriodStartsAt: String

init?(userInfo: [AnyHashable: Any]) {
guard let payerIdentity = userInfo["payer_identity"] as? String,
let paymentRequestId = userInfo["payment_request_id"] as? String,
let counterparty = userInfo["counterparty"] as? String,
let counterpartyReceiverPath = userInfo["counterparty_receiver_path"] as? String,
let billingPeriodStartsAt = userInfo["billing_period_starts_at"] as? String
else { return nil }

self.payerIdentity = payerIdentity
self.paymentRequestId = paymentRequestId
self.counterparty = counterparty
self.counterpartyReceiverPath = counterpartyReceiverPath
self.billingPeriodStartsAt = billingPeriodStartsAt
}

func matches(_ request: PaykitPaymentRequest) -> Bool {
paymentRequestId == request.paymentRequestId &&
PubkyPublicKeyFormat.matches(counterparty, request.counterparty) &&
counterpartyReceiverPath == request.counterpartyReceiverPath &&
request.billingPeriod.map {
PaykitSubscriptionTimestamp.string(from: $0.startsAt) == billingPeriodStartsAt
} == true
}

func matches(_ requestId: PaykitPaymentRequest.ID) -> Bool {
paymentRequestId == requestId.paymentRequestId &&
PubkyPublicKeyFormat.matches(counterparty, requestId.counterparty) &&
counterpartyReceiverPath == requestId.counterpartyReceiverPath &&
requestId.billingPeriodStartsAt.map {
PaykitSubscriptionTimestamp.string(from: $0) == billingPeriodStartsAt
} == true
}

func matches(identity: String) -> Bool {
PubkyPublicKeyFormat.matches(payerIdentity, identity)
}
}

enum PaykitSubscriptionNotificationTargetStore {
private static let key = "paykitSubscriptionNotificationTarget"

static func save(_ target: PaykitSubscriptionNotificationTarget) {
guard let data = try? JSONEncoder().encode(target) else { return }
UserDefaults.standard.set(data, forKey: key)
}

static func load() -> PaykitSubscriptionNotificationTarget? {
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(PaykitSubscriptionNotificationTarget.self, from: data)
}

static func clear() {
UserDefaults.standard.removeObject(forKey: key)
}
}

class AppDelegate: NSObject, UIApplicationDelegate {
Expand Down Expand Up @@ -39,6 +104,15 @@ class AppDelegate: NSObject, UIApplicationDelegate {
return config
}

func application(
_ application: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
DeepLinkRouter.shared.forward(url)
return true
}

// MARK: - App Termination

func applicationWillTerminate(_ application: UIApplication) {
Expand Down Expand Up @@ -81,7 +155,14 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
) {
let userInfo = response.notification.request.content.userInfo

PushNotificationManager.shared.handleNotification(userInfo)
if userInfo["bitkit_action"] as? String == "paykit_subscription_due" {
if let target = PaykitSubscriptionNotificationTarget(userInfo: userInfo) {
PaykitSubscriptionNotificationTargetStore.save(target)
}
NotificationCenter.default.post(name: .paykitSubscriptionPaymentDue, object: nil, userInfo: userInfo)
} else {
PushNotificationManager.shared.handleNotification(userInfo)
}

// TODO: if user tapped on an incoming tx we should open it on that tx view
completionHandler()
Expand Down
15 changes: 5 additions & 10 deletions Bitkit/Components/Button/SecondaryButtonView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct SecondaryButtonView: View {
}
}
.frame(maxWidth: (size == .large || shouldExpand) ? .infinity : nil)
.frame(height: buttonHeight)
.frame(height: size.height)
.padding(.horizontal, 16)
.background(isPressed ? Color.white10 : Color.clear)
.background(BlurView())
Expand All @@ -36,18 +36,13 @@ struct SecondaryButtonView: View {
}

private var textColor: Color {
isDisabled ? .white32 : .white80
guard !isDisabled else { return .white32 }
return size == .small ? .white64 : .white80
}

private var borderColor: Color {
isDisabled ? .clear : .gray4
}

private var buttonHeight: CGFloat {
switch size {
case .small: 37
case .large: 56
}
guard !isDisabled else { return .clear }
return size == .small ? .white16 : .gray4
}

private var strokeWidth: CGFloat {
Expand Down
Loading
Loading