Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = exactVersion;
version = 0.1.0-rc5;
version = "0.1.0-rc5";
};
};
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
Expand All @@ -930,8 +930,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/synonymdev/ldk-node";
requirement = {
kind = revision;
revision = 52f73c4402cfb06a020ab8fa9594b5ecb94e3cd6;
kind = exactVersion;
version = "0.7.0-rc.39";
};
};
96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = {
Expand Down

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

34 changes: 33 additions & 1 deletion Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ struct AppScene: View {
_settings = StateObject(wrappedValue: SettingsViewModel.shared)

_transferTracking = StateObject(wrappedValue: TransferTrackingManager(service: transferService))

CoreService.shared.activity.setPrivatePaykitContactResolvers(
invoice: { paymentHash in
await PrivatePaykitService.shared.contactPublicKey(forPrivateInvoicePaymentHash: paymentHash)
},
onchainAddress: { address in
await PrivatePaykitAddressReservationStore.shared.contactPublicKey(forReservedAddress: address)
}
)
}

var body: some View {
Expand All @@ -91,7 +100,7 @@ struct AppScene: View {
.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) { _, newValue in handleScenePhaseChange(newValue) }
.onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) }
.onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) }
.onChange(of: migrations.isShowingMigrationLoading) { _, isLoading in
if !isLoading {
Expand Down Expand Up @@ -142,6 +151,13 @@ struct AppScene: View {
contactsManager.reset()
}
}
.onReceive(contactsManager.$contacts) { contacts in
guard wallet.walletExists == true, pubkyProfile.authState == .authenticated else { return }
let publicKeys = contacts.map(\.publicKey)
Task {
await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet)
}
}
.onChange(of: navigation.currentRoute) { oldRoute, newRoute in
guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else {
return
Expand Down Expand Up @@ -527,6 +543,13 @@ struct AppScene: View {
walletInitShouldFinish = true
app.markAppStatusInit()
BackupService.shared.startObservingBackups()
Task {
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
await PrivatePaykitService.shared.prepareSavedContacts(
contactsManager.contacts.map(\.publicKey),
wallet: wallet
)
}
} else {
if case .errorStarting = state {
walletInitShouldFinish = true
Expand All @@ -552,7 +575,16 @@ struct AppScene: View {
Task {
await clearDeliveredNotifications()
await LightningService.shared.reconnectPeers()
try? await wallet.sync()
await PrivatePaykitService.shared.retryPendingEndpointRemoval(
wallet: wallet,
savedPublicKeys: contactsManager.contacts.map(\.publicKey)
)
await wallet.refreshPublicPaykitEndpointsOnForeground()
await PrivatePaykitService.shared.refreshSavedContactEndpoints(
for: contactsManager.contacts.map(\.publicKey),
wallet: wallet
)
}
}
}
Expand Down
17 changes: 11 additions & 6 deletions Bitkit/Constants/Env.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ enum Env {
/// Returns the keychain access group based on the current network
static var keychainGroup: String {
let base = "KYH47R284B.to.bitkit"
let networkSuffix = networkName(network)
let networkSuffix = networkName
return networkSuffix == "bitcoin" ? base : "\(base).\(networkSuffix)"
}

Expand Down Expand Up @@ -106,8 +106,13 @@ enum Env {
}
}

/// Returns the lowercase name of the network (e.g., "bitcoin", "testnet", "signet", "regtest")
private static func networkName(_ network: LDKNode.Network) -> String {
/// Lowercase storage/display name for the current network.
static var networkName: String {
networkName(for: network)
}

/// Lowercase storage/display name for a network.
static func networkName(for network: LDKNode.Network) -> String {
switch network {
case .bitcoin: "bitcoin"
case .testnet: "testnet"
Expand Down Expand Up @@ -150,13 +155,13 @@ enum Env {

static func ldkStorage(walletIndex: Int) -> URL {
appStorageUrl
.appendingPathComponent(networkName(network))
.appendingPathComponent(networkName)
.appendingPathComponent("wallet\(walletIndex)/ldk")
}

static func bitkitCoreStorage(walletIndex: Int) -> URL {
appStorageUrl
.appendingPathComponent(networkName(network))
.appendingPathComponent(networkName)
.appendingPathComponent("wallet\(walletIndex)/core")
}

Expand Down Expand Up @@ -242,7 +247,7 @@ enum Env {
]

static var vssStoreIdPrefix: String {
"bitkit_v1_\(networkName(network))"
"bitkit_v1_\(networkName)"
}

static var vssServerUrl: String {
Expand Down
12 changes: 12 additions & 0 deletions Bitkit/Managers/ContactsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ enum ContactsManagerError: LocalizedError {

// MARK: - PubkyContact

// swiftformat:disable:next redundantSendable
struct PubkyContact: Identifiable, Hashable, Sendable {
let id: String
let publicKey: String
Expand Down Expand Up @@ -173,6 +174,10 @@ class ContactsManager: ObservableObject {
let contactPaths = try await Task.detached {
try await PubkyService.sessionList(sessionSecret: sessionSecret, dirPath: basePath)
}.value
let savedContactKeys = contactPaths
.map(extractPublicKey(from:))
.filter { !$0.isEmpty }
.map(ensurePubkyPrefix)

Logger.debug("Listed \(contactPaths.count) contacts from homeserver", context: "ContactsManager")

Expand Down Expand Up @@ -226,6 +231,7 @@ class ContactsManager: ObservableObject {

if !contactPaths.isEmpty, loadedResult.contacts.isEmpty {
if loadedResult.failures == loadedResult.missingFailures {
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
contacts = []
hasLoaded = true
Logger.info("Contacts storage entries were missing, treating list as empty", context: "ContactsManager")
Expand All @@ -235,6 +241,7 @@ class ContactsManager: ObservableObject {
}

contacts = loadedResult.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: savedContactKeys)
hasLoaded = true

if loadedResult.failures > 0 {
Expand All @@ -247,6 +254,7 @@ class ContactsManager: ObservableObject {
Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager")
} catch {
if Self.isMissingContactsDataError(error) {
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
contacts = []
hasLoaded = true
loadErrorMessage = nil
Expand Down Expand Up @@ -403,6 +411,7 @@ class ContactsManager: ObservableObject {
Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager")

contacts.removeAll { $0.publicKey == prefixedKey }
await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey)
}

/// Delete all contacts from the homeserver and keep local state in sync with completed deletions.
Expand All @@ -418,6 +427,7 @@ class ContactsManager: ObservableObject {
}.value
} catch {
if Self.isMissingContactsDataError(error) {
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
contacts.removeAll()
return
}
Expand Down Expand Up @@ -448,11 +458,13 @@ class ContactsManager: ObservableObject {
if let firstError {
if !deletedKeys.isEmpty {
contacts.removeAll { deletedKeys.contains($0.publicKey) }
await PrivatePaykitService.shared.removeSavedContacts(publicKeys: Array(deletedKeys))
}
throw firstError
}

// All remote deletes succeeded, so clear any local-only contacts too.
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
contacts.removeAll()
Logger.info("Deleted all contacts", context: "ContactsManager")
}
Expand Down
12 changes: 12 additions & 0 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,8 @@ class PubkyProfileManager: ObservableObject {
// MARK: - Sign Out

static func clearLocalState() async {
await PrivatePaykitService.shared.closeAndClear()
await PrivatePaykitAddressReservationStore.shared.clearContactAssignments()
await PubkyService.forceSignOut()
try? Keychain.delete(key: .paykitSession)
try? Keychain.delete(key: .pubkySecretKey)
Expand All @@ -766,6 +768,7 @@ class PubkyProfileManager: ObservableObject {
private static func clearPublicPaykitSharingState() {
UserDefaults.standard.set(false, forKey: "sharesPublicPaykitEndpoints")
UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints")
PrivatePaykitService.setContactSharingCleanupPending(false)
UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11")
UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11PaymentHash")
UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11ExpiresAt")
Expand All @@ -786,9 +789,18 @@ class PubkyProfileManager: ObservableObject {
try? await removePublicPaykitEndpoints(context: context)
}

static func removePrivatePaykitEndpointsBestEffort(context: String) async {
do {
try await PrivatePaykitService.shared.removePublishedEndpoints()
} catch {
Logger.warn("Failed to remove private Paykit endpoints before clearing session: \(error)", context: context)
}
}

func signOut() async {
await Task.detached {
await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut")
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut")
do {
try await PubkyService.signOut()
} catch {
Expand Down
14 changes: 14 additions & 0 deletions Bitkit/Models/BackupPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ struct WalletBackupV1: Codable {
let version: Int
let createdAt: UInt64
let transfers: [Transfer]
let privatePaykitHighestReservedReceiveIndexByAddressType: [String: UInt32]?
let privatePaykitContactLinks: [String: PrivatePaykitContactLinkBackupV1]?
}

struct PrivatePaykitContactLinkBackupV1: Codable, Equatable {
let publicKey: String
let linkSnapshotHex: String?
let handshakeSnapshotHex: String?
let remoteEndpoints: [String: String]
let linkCompletedAt: UInt64?
let handshakeUpdatedAt: UInt64?
let recoveryStartedAt: UInt64?
let mainRecoveryAttemptId: String?
let responderRecoveryAttemptId: String?
}

struct MetadataBackupV1: Codable {
Expand Down
28 changes: 26 additions & 2 deletions Bitkit/Services/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ class BackupService {
try await performRestore(category: .wallet) { dataBytes in
let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes)
try TransferStorage.shared.upsertList(payload.transfers)
await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType)
await PrivatePaykitService.shared.restoreBackup(payload.privatePaykitContactLinks)
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()

Logger.debug("Restored \(payload.transfers.count) transfers", context: "BackupService")
}
Expand Down Expand Up @@ -321,6 +324,23 @@ class BackupService {
}
.store(in: &cancellables)

// PRIVATE PAYKIT WALLET DATA
PrivatePaykitAddressReservationStore.walletBackupDataChangedPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, !self.shouldSkipBackup() else { return }
markBackupRequired(category: .wallet)
}
.store(in: &cancellables)

PrivatePaykitService.walletBackupDataChangedPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, !self.shouldSkipBackup() else { return }
markBackupRequired(category: .wallet)
}
.store(in: &cancellables)

// ACTIVITIES
CoreService.shared.activity.activitiesChangedPublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
Expand Down Expand Up @@ -374,7 +394,7 @@ class BackupService {
}
.store(in: &cancellables)

Logger.debug("Started 7 data store listeners", context: "BackupService")
Logger.debug("Started 9 data store listeners", context: "BackupService")
}

private func startPeriodicBackupFailureCheck() {
Expand Down Expand Up @@ -653,10 +673,14 @@ class BackupService {

case .wallet:
let transfers = try TransferStorage.shared.getAll()
let privatePaykitHighestReservedReceiveIndexByAddressType = await PrivatePaykitAddressReservationStore.shared.backupSnapshot()
let privatePaykitContactLinks = await PrivatePaykitService.shared.backupSnapshot()
let payload = WalletBackupV1(
version: 1,
createdAt: UInt64(Date().timeIntervalSince1970 * 1000),
transfers: transfers
transfers: transfers,
privatePaykitHighestReservedReceiveIndexByAddressType: privatePaykitHighestReservedReceiveIndexByAddressType,
privatePaykitContactLinks: privatePaykitContactLinks
)
return try JSONEncoder().encode(payload)

Expand Down
Loading
Loading