Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8b6cd5d
fix: bind profile removal to its original owner
ben-kaufman Oct 27, 2026
2ca19ed
chore: rename changelog fragment
ben-kaufman Oct 27, 2026
0eef45d
Integrate recovery updates and harden profile removal
ben-kaufman Oct 27, 2026
60b1485
Merge finalized recovery dependency
ben-kaufman Oct 27, 2026
91379a5
test: synchronize profile admission and keyboard setup
ben-kaufman Oct 28, 2026
f1fa5ee
fix: retain receipt contacts during profile cleanup
ben-kaufman Oct 28, 2026
1f1bead
fix: drain receipt and restore writes before wallet wipe
ben-kaufman Oct 28, 2026
86fb0c5
Merge validated profile cleanup dependency
ben-kaufman Oct 28, 2026
9519507
Merge active subscription guards into profile removal recovery
ben-kaufman Oct 29, 2026
acc028f
test: synchronize profile creation admission setup
ben-kaufman Oct 29, 2026
08f2990
test: expose reservation cleanup acknowledgement fault
ben-kaufman Oct 29, 2026
b426e65
fix: allow local disconnect when subscriptions block removal
ben-kaufman Oct 29, 2026
2aed345
fix: describe local disconnect without remote state promises
ben-kaufman Oct 29, 2026
81ef10a
Merge current SDK queue parent and preserve profile admission
ben-kaufman Oct 29, 2026
594688f
Merge #1125 while preserving profile removal admission
ben-kaufman Oct 29, 2026
6e5dccb
Require acknowledged endpoint withdrawal with Paykit RC3
ben-kaufman Oct 30, 2026
2c6cbdb
test: isolate auth cancellation admission
ben-kaufman Oct 30, 2026
0e4ab09
fix: retain original grant cleanup and disabled sharing withdrawals
ben-kaufman Oct 30, 2026
3e49570
fix: integrate profile removal with RC4 sharing
ben-kaufman Oct 30, 2026
d6c1d9a
fix: retire unavailable profile retries at confirmation
ben-kaufman Oct 31, 2026
f6b4587
fix: register public sharing intent before admission
ben-kaufman Oct 31, 2026
630be75
fix: retry unfinished public profile cleanup
ben-kaufman Oct 31, 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
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1200,7 +1200,7 @@
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = exactVersion;
version = "0.1.1-rc2";
version = "0.1.1-rc4";
};
};
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 74 additions & 56 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -501,21 +501,24 @@ struct AppScene: View {
private func configurePrivatePaykitContactResolvers() {
CoreService.shared.activity.setPrivatePaykitContactResolvers(
invoice: { paymentHash in
await PrivatePaykitService.shared.contactPublicKey(forPrivateInvoicePaymentHash: paymentHash)
await PrivatePaykitService.shared.contactPublicKeyForReceipt(paymentHash: paymentHash)
},
onchainAddresses: { @MainActor address, outputAddresses in
let identity = pubkyProfile.publicKey
let contacts = paykitPaymentRequestManager.receivedPaymentContacts
guard (try? ProfileOperationAdmission.shared.checkReceiptAttribution()) != nil else { return nil }
let reservations = PrivatePaykitAddressReservationStore.shared
let reservationRevision = await reservations.attributionRevision
guard let combined = try? await contacts.includingReservations(for: outputAddresses, lookup: {
try await reservations.contactPublicKeyForAttribution(forReservedAddress: $0)
}), pubkyProfile.authState == .authenticated,
PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey),
contacts == paykitPaymentRequestManager.receivedPaymentContacts,
await reservations.attributionRevision == reservationRevision
let revision = await reservations.attributionRevision
let contacts = pubkyProfile.authState == .authenticated
? paykitPaymentRequestManager.receivedPaymentContacts : PaykitReceivedPaymentContacts()
guard let combined = try? await reservations.contactsForReceipt(
contacts,
outputAddresses: outputAddresses,
expectedRevision: revision
)
else { return nil }
return combined.contact(receivingAddress: address, outputAddresses: outputAddresses)
},
receiptOperation: { operation in
try await ProfileOperationAdmission.shared.withReceiptAttribution(operation)
}
)
}
Expand All @@ -541,7 +544,7 @@ struct AppScene: View {
) {
config in AppUpdateSheet(config: config)
}
.task(priority: .userInitiated, setupTask)
.task(id: pubkyProfile.removalRecovery.blocksWalletStartup, priority: .userInitiated, setupTask)
.task(id: [scenePhase == .active, wallet.walletExists == true, isWalletBackupRestoreRunning, network.isConnected]) {
guard scenePhase == .active, wallet.walletExists == true,
!isWalletBackupRestoreRunning, !BackupService.shared.hasPendingWalletRestore()
Expand Down Expand Up @@ -825,13 +828,15 @@ struct AppScene: View {
}

private var isLockedForDeepLinks: Bool {
settings.pinEnabled && !isPinVerified
pubkyProfile.removalRecovery.blocksWalletStartup || (settings.pinEnabled && !isPinVerified)
}

private var mainContent: some View {
ZStack {
if Env.isTrezorEmulatorTesting {
trezorEmulatorTestContent
} else if pubkyProfile.removalRecovery.blocksWalletStartup && !hasCriticalUpdate {
NavigationStack { ProfileRemovalRecoveryView() }
} else if migrations.isShowingMigrationLoading {
migrationLoadingContent
} else if showRecoveryScreen {
Expand All @@ -843,7 +848,7 @@ struct AppScene: View {
walletContent
}

if !Env.isTrezorEmulatorTesting, !removeSplash, !session.skipSplashOnce {
if !Env.isTrezorEmulatorTesting, !pubkyProfile.removalRecovery.blocksWalletStartup, !removeSplash, !session.skipSplashOnce {
SplashView()
.opacity(hideSplash ? 0 : 1)
}
Expand Down Expand Up @@ -994,7 +999,7 @@ struct AppScene: View {
}
}

guard wallet.walletExists == true else { return }
guard wallet.walletExists == true, !pubkyProfile.removalRecovery.blocksWalletStartup else { return }

// Don't start wallet if we're in recovery mode
guard !showRecoveryScreen else { return }
Expand Down Expand Up @@ -1024,6 +1029,7 @@ struct AppScene: View {
}

private func startWallet(completingBackupRestore: Bool = false, playsErrorHaptic: Bool = true) async {
guard !pubkyProfile.removalRecovery.blocksWalletStartup else { return }
let hasPendingRestore = BackupService.shared.hasPendingWalletRestore()
guard !WalletBackupRestoreGate.blocksWalletStart(
isRestoreRunning: isWalletBackupRestoreRunning,
Expand Down Expand Up @@ -1109,6 +1115,8 @@ struct AppScene: View {
private func setupTask() async {
configurePrivatePaykitContactResolvers()
AppReset.hardwareWallets = hwWalletManager
pubkyProfile.refreshRemovalRecovery()
guard !pubkyProfile.removalRecovery.blocksWalletStartup else { return }
do {
// Handle orphaned keychain before anything else
handleOrphanedKeychain()
Expand Down Expand Up @@ -1176,26 +1184,32 @@ struct AppScene: View {
}

private func restoreWalletBackupAndStart() async {
guard !isWalletBackupRestoreRunning else { return }
guard !isWalletBackupRestoreRunning, !pubkyProfile.removalRecovery.blocksWalletStartup else { return }
isWalletBackupRestoreRunning = true
walletIsInitializing = true
didWalletBackupRestoreFail = false
defer { isWalletBackupRestoreRunning = false }

let didRestore: Bool = if BackupService.shared.hasPendingWalletRestore() {
await restoreVssBackup()
} else {
await restoreFromMostRecentBackup()
}
guard didRestore else {
do {
let didRestore = try await ProfileOperationAdmission.shared.withWalletRestore {
let restored: Bool = if BackupService.shared.hasPendingWalletRestore() {
await restoreVssBackup()
} else {
await restoreFromMostRecentBackup()
}
guard restored else { return false }

widgets.loadSavedWidgets()
widgets.objectWillChange.send()
pubkyProfile.scheduleInitializationAfterRestore()
await startWallet(completingBackupRestore: true)
return true
}
didWalletBackupRestoreFail = !didRestore
} catch {
Logger.warn("Wallet backup restore could not complete: \(error)", context: "AppScene")
didWalletBackupRestoreFail = true
return
}

widgets.loadSavedWidgets()
widgets.objectWillChange.send()
pubkyProfile.scheduleInitializationAfterRestore()
await startWallet(completingBackupRestore: true)
}

private func retryWalletStart() async {
Expand All @@ -1210,7 +1224,7 @@ struct AppScene: View {
}

private func retryPendingWalletRestoreIfNeeded() -> Bool {
if isWalletBackupRestoreRunning {
if pubkyProfile.removalRecovery.blocksWalletStartup || ProfileOperationAdmission.shared.isWalletWiping || isWalletBackupRestoreRunning {
return true
}
guard BackupService.shared.hasPendingWalletRestore() else { return false }
Expand Down Expand Up @@ -1249,7 +1263,9 @@ struct AppScene: View {

if shouldRestoreRN {
do {
try await MigrationsService.shared.restoreFromRNRemoteBackup(mnemonic: mnemonic, passphrase: passphrase)
try await MigrationsService.shared.restoreFromRNRemoteBackup(mnemonic: mnemonic, passphrase: passphrase) {
try await ProfileOperationAdmission.shared.withWalletRestore($0)
}
} catch {
Logger.error("RN remote backup restore failed: \(error)", context: "AppScene")
// Fall back to VSS
Expand Down Expand Up @@ -2061,35 +2077,37 @@ struct AppScene: View {
}

private func startAutomaticPaykitSession() {
guard let session = pubkyProfile.automaticPaykitSession,
!PubkyPublicKeyFormat.matches(session.publicKey, activatedAutomaticPaykitIdentity)
else { return }
let pk = session.publicKey
activatedAutomaticPaykitIdentity = pk
Task {
await paykitAllowanceManager.activate(identity: pk)
await handlePendingPaykitSubscriptionNotification()
guard pubkyProfile.automaticPaykitSession == session else {
// A deletion or session change cut this start short; let the session's next appearance run it again.
if activatedAutomaticPaykitIdentity == pk {
activatedAutomaticPaykitIdentity = nil
do {
try pubkyProfile.scheduleAutomaticPaykitSession { session in
let pk = session.publicKey
guard !PubkyPublicKeyFormat.matches(pk, activatedAutomaticPaykitIdentity) else { return }
activatedAutomaticPaykitIdentity = pk
await paykitAllowanceManager.activate(identity: pk)
await handlePendingPaykitSubscriptionNotification()
guard pubkyProfile.automaticPaykitSession == session else {
// A deletion or session change cut this start short; let the session's next appearance run it again.
if activatedAutomaticPaykitIdentity == pk {
activatedAutomaticPaykitIdentity = nil
}
// A new session for the same key may already have appeared while this one waited.
startAutomaticPaykitSession()
return
}
try? await contactsManager.loadContacts(for: pk)
if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true {
await retryPendingPaykitEndpointRemoval()
}
guard isPaykitSceneActive, pubkyProfile.authState == .authenticated,
PubkyPublicKeyFormat.matches(pk, pubkyProfile.publicKey)
else { return }
await refreshPrivateOnlyPaykitApp()
await refreshIncomingPaykitPaymentRequests(presentItems: false, onlyWhileActive: true)
if PaykitSubscriptionNotificationTargetStore.load() == nil {
await presentNextIncomingPaykitItem()
}
// A new session for the same key may already have appeared while this one waited.
startAutomaticPaykitSession()
return
}
try? await contactsManager.loadContacts(for: pk)
if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true {
await retryPendingPaykitEndpointRemoval()
}
guard isPaykitSceneActive, pubkyProfile.authState == .authenticated,
PubkyPublicKeyFormat.matches(pk, pubkyProfile.publicKey)
else { return }
await refreshPrivateOnlyPaykitApp()
await refreshIncomingPaykitPaymentRequests(presentItems: false, onlyWhileActive: true)
if PaykitSubscriptionNotificationTargetStore.load() == nil {
await presentNextIncomingPaykitItem()
}
} catch {
Logger.debug("Automatic Paykit startup was not admitted: \(error)", context: "AppScene")
}
}

Expand Down
30 changes: 23 additions & 7 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,9 @@ struct MainNavView: View {
} else if let profile = contactsManager.pendingImportProfile {
ContactImportOverviewView(
profile: profile,
contacts: contactsManager.pendingImportContacts
contacts: contactsManager.pendingImportContacts,
importGeneration: contactsManager.importGeneration,
owner: pubkyProfile.removalPresentationOwner
)
} else {
missingPendingImportView(fallbackRoute: .payContacts)
Expand All @@ -577,7 +579,11 @@ struct MainNavView: View {
} else if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) {
missingPendingImportView(fallbackRoute: fallbackRoute)
} else {
ContactImportSelectView(contacts: contactsManager.pendingImportContacts)
ContactImportSelectView(
contacts: contactsManager.pendingImportContacts,
importGeneration: contactsManager.importGeneration,
owner: pubkyProfile.removalPresentationOwner
)
}
case let .addContact(publicKey):
if isPaykitUIActive {
Expand All @@ -592,7 +598,9 @@ struct MainNavView: View {
paykitDisabledRedirectView
}
case .profile:
if !isPaykitUIActive {
if pubkyProfile.removalRecovery != .ready {
ProfileRemovalRecoveryView()
} else if !isPaykitUIActive {
ComingSoonScreen()
} else if let initializationErrorMessage = pubkyProfile.initializationErrorMessage {
pubkyInitializationErrorView(message: initializationErrorMessage)
Expand All @@ -602,25 +610,33 @@ struct MainNavView: View {
ProfileDestinationView(hasSeenIntro: app.hasSeenProfileIntro)
}
case .profileIntro:
if isPaykitUIActive {
if pubkyProfile.removalRecovery != .ready {
ProfileRemovalRecoveryView()
} else if isPaykitUIActive {
ProfileIntroView()
} else {
ComingSoonScreen()
}
case .pubkyChoice:
if isPaykitUIActive {
if pubkyProfile.removalRecovery != .ready {
ProfileRemovalRecoveryView()
} else if isPaykitUIActive {
PubkyChoiceView()
} else {
paykitDisabledRedirectView
}
case .createProfile:
if isPaykitUIActive {
if pubkyProfile.removalRecovery != .ready {
ProfileRemovalRecoveryView()
} else if isPaykitUIActive {
CreateProfileView()
} else {
paykitDisabledRedirectView
}
case .editProfile:
if isPaykitUIActive {
if pubkyProfile.removalRecovery != .ready {
ProfileRemovalRecoveryView()
} else if isPaykitUIActive {
EditProfileView()
} else {
paykitDisabledRedirectView
Expand Down
Loading
Loading