diff --git a/.gitignore b/.gitignore
index 68d7db447..be326f68b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,5 @@ buildServer.json
# AIs
.ai/
+.codex/
.claude/*.local*
diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj
index ed3072b5f..d5bda3c6a 100644
--- a/Bitkit.xcodeproj/project.pbxproj
+++ b/Bitkit.xcodeproj/project.pbxproj
@@ -907,7 +907,7 @@
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = exactVersion;
- version = "0.1.0-rc5";
+ version = "0.1.0-rc6";
};
};
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 920344ed9..9983b8172 100644
--- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pubky/paykit-rs",
"state" : {
- "revision" : "04759b7b9bca7a0dd8a29e724f11e984db361241",
- "version" : "0.1.0-rc5"
+ "revision" : "424b7703c7a83df3a0f2553274a76ed815659211",
+ "version" : "0.1.0-rc6"
}
},
{
diff --git a/Bitkit/Assets.xcassets/icons/user-minus.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/user-minus.imageset/Contents.json
new file mode 100644
index 000000000..83fa61d16
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/user-minus.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "user-minus.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Bitkit/Assets.xcassets/icons/user-minus.imageset/user-minus.svg b/Bitkit/Assets.xcassets/icons/user-minus.imageset/user-minus.svg
new file mode 100644
index 000000000..eb18c8ebf
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/user-minus.imageset/user-minus.svg
@@ -0,0 +1,6 @@
+
diff --git a/Bitkit/Components/PubkyContactAvatar.swift b/Bitkit/Components/PubkyContactAvatar.swift
new file mode 100644
index 000000000..6de9069c7
--- /dev/null
+++ b/Bitkit/Components/PubkyContactAvatar.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+struct PubkyContactAvatar: View {
+ let name: String
+ let imageUrl: String?
+ let size: CGFloat
+
+ init(name: String, imageUrl: String?, size: CGFloat) {
+ self.name = name
+ self.imageUrl = imageUrl
+ self.size = size
+ }
+
+ init(contact: PubkyContact, size: CGFloat) {
+ name = contact.displayName
+ imageUrl = contact.profile.imageUrl
+ self.size = size
+ }
+
+ var body: some View {
+ Group {
+ if let imageUrl {
+ PubkyImage(uri: imageUrl, size: size)
+ } else {
+ ContactAvatarLetter(source: name, size: size)
+ }
+ }
+ .accessibilityHidden(true)
+ }
+}
diff --git a/Bitkit/Components/PubkyContactRow.swift b/Bitkit/Components/PubkyContactRow.swift
new file mode 100644
index 000000000..f1c95b754
--- /dev/null
+++ b/Bitkit/Components/PubkyContactRow.swift
@@ -0,0 +1,42 @@
+import SwiftUI
+
+struct PubkyContactRow: View {
+ let contact: PubkyContact
+ var verticalPadding: CGFloat = 12
+ var showsDivider = true
+ var isLoading = false
+ let action: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Button(action: action) {
+ HStack(spacing: 16) {
+ PubkyContactAvatar(contact: contact, size: 48)
+
+ VStack(alignment: .leading, spacing: 4) {
+ CaptionText(contact.profile.truncatedPublicKey.localizedUppercase)
+ .lineLimit(1)
+
+ BodyMSBText(contact.displayName)
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ if isLoading {
+ ProgressView()
+ }
+ }
+ .padding(.vertical, verticalPadding)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(isLoading)
+ .accessibilityLabel(contact.displayName)
+
+ if showsDivider {
+ CustomDivider()
+ }
+ }
+ }
+}
diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift
index b8e12f975..570b8e26f 100644
--- a/Bitkit/MainNavView.swift
+++ b/Bitkit/MainNavView.swift
@@ -386,6 +386,7 @@ struct MainNavView: View {
case .contactsIntro: ContactsIntroView()
case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey)
case let .contactActivity(publicKey): ContactActivityView(publicKey: publicKey)
+ case let .assignActivityContact(activityId): AssignActivityContactView(activityId: activityId)
case .contactImportOverview:
if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) {
missingPendingImportView(fallbackRoute: fallbackRoute)
@@ -450,6 +451,7 @@ struct MainNavView: View {
case .widgetsSettings: WidgetsSettingsScreen()
case .notifications: NotificationsSettings()
case .notificationsIntro: NotificationsIntro()
+ case .paymentPreference: PaymentPreferenceView()
// Security settings
case .changePin: ChangePinScreen()
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index 8b111fe5d..4f473efbc 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -602,7 +602,10 @@ class PubkyProfileManager: ObservableObject {
}
do {
+ try? Keychain.delete(key: .pubkySecretKey)
try Self.upsertKeychainString(.paykitSession, value: sessionSecret)
+ UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey)
+ PrivatePaykitService.setProfileRecoveryPending(false)
Self.notifyAppStateBackupChanged()
} catch {
await PubkyService.forceSignOut()
@@ -753,7 +756,7 @@ class PubkyProfileManager: ObservableObject {
// MARK: - Sign Out
static func clearLocalState() async {
- await PrivatePaykitService.shared.closeAndClear()
+ await PrivatePaykitService.shared.closeAndClear(markProfileRecoveryPending: true)
await PrivatePaykitAddressReservationStore.shared.clearContactAssignments()
await PubkyService.forceSignOut()
try? Keychain.delete(key: .paykitSession)
@@ -766,7 +769,8 @@ class PubkyProfileManager: ObservableObject {
}
private static func clearPublicPaykitSharingState() {
- UserDefaults.standard.set(false, forKey: "sharesPublicPaykitEndpoints")
+ UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey)
+ UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey)
UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints")
PrivatePaykitService.setContactSharingCleanupPending(false)
UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11")
@@ -870,6 +874,23 @@ class PubkyProfileManager: ObservableObject {
publicKey != nil
}
+ var hasLocalSecretKeyForCurrentProfile: Bool {
+ Self.hasLocalSecretKey(for: publicKey)
+ }
+
+ nonisolated static func hasLocalSecretKey(for publicKey: String?) -> Bool {
+ guard let publicKey,
+ let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey),
+ !secretKeyHex.isEmpty,
+ let rawPublicKey = try? PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)
+ else {
+ return false
+ }
+
+ let prefixedPublicKey = rawPublicKey.hasPrefix("pubky") ? rawPublicKey : "pubky\(rawPublicKey)"
+ return PubkyPublicKeyFormat.matches(prefixedPublicKey, publicKey)
+ }
+
nonisolated static func snapshotSessionBackupState(
loadKeychainString: (KeychainEntryType) throws -> String? = {
try Keychain.loadString(key: $0)
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index dc51a52c1..130f2965a 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -816,7 +816,7 @@
"settings__adv__pp_header" = "Choose how you prefer to receive money when users send funds to your profile key.";
"settings__adv__pp_footer" = "* This requires sharing payment data.";
"settings__adv__pp_drag" = "Payment preference (drag to reorder)";
-"settings__adv__pp_contacts" = "Pay to/from contacts";
+"settings__adv__pp_contacts" = "Payments from contacts";
"settings__adv__pp_contacts_switch" = "Enable payments with contacts*";
"settings__adv__address_viewer" = "Address Viewer";
"settings__adv__sweep_funds" = "Sweep Funds";
@@ -1416,3 +1416,13 @@
"widgets__weather__current_fee" = "Current average fee";
"widgets__weather__next_block" = "Next block inclusion";
"widgets__weather__error" = "Couldn\'t get current fee weather";
+
+"settings__adv__pp_options" = "Payment options";
+"settings__adv__pp_lightning" = "Lightning (Bitkit)";
+"settings__adv__pp_onchain" = "On-chain (Bitkit)";
+"settings__adv__pp_private_contacts" = "Private payments with contacts";
+"settings__adv__pp_public_contacts" = "Public payments with contacts*";
+"settings__adv__pp_public_footer" = "*Public payments with contacts requires payment data to be shared publicly.";
+"settings__adv__pp_both" = "Both";
+"settings__adv__pp_lightning_short" = "Lightning";
+"settings__adv__pp_onchain_short" = "On-chain";
diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift
index 2da1b7e57..08babc933 100644
--- a/Bitkit/Services/PrivatePaykitService+Backup.swift
+++ b/Bitkit/Services/PrivatePaykitService+Backup.swift
@@ -41,6 +41,7 @@ extension PrivatePaykitService {
guard let backup else {
state = PrivatePaykitState(contacts: [:])
persistState()
+ Self.setProfileRecoveryPending(false)
return
}
@@ -72,6 +73,7 @@ extension PrivatePaykitService {
state = PrivatePaykitState(contacts: restoredContacts)
persistState()
+ Self.setProfileRecoveryPending(false)
}
func validatedSnapshot(
diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift
index 77b94cdb9..95c304416 100644
--- a/Bitkit/Services/PrivatePaykitService+Contacts.swift
+++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift
@@ -3,22 +3,103 @@ import Foundation
// MARK: - Saved Contacts
extension PrivatePaykitService {
- func prepareSavedContacts(_ publicKeys: [String], wallet: WalletViewModel) async {
+ @discardableResult
+ func prepareSavedContacts(
+ _ publicKeys: [String],
+ wallet: WalletViewModel,
+ requireImmediatePublication: Bool = false
+ ) async -> Error? {
let publicKeys = rememberSavedContacts(publicKeys, replacing: true)
- guard await canPublishPrivateEndpoints(wallet: wallet) else { return }
+ guard await canPublishPrivateEndpoints(wallet: wallet) else { return nil }
+ if Self.isProfileRecoveryPending, !publicKeys.isEmpty {
+ return await recoverSavedContactsAfterProfileRecreation(
+ publicKeys,
+ wallet: wallet,
+ requireImmediatePublication: requireImmediatePublication
+ )
+ }
+ await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
+ return await publishLocalEndpoints(
+ for: publicKeys,
+ wallet: wallet,
+ maxAdvanceSteps: 3,
+ reason: "prepare",
+ requireImmediatePublication: requireImmediatePublication
+ )
+ }
+
+ @discardableResult
+ func recoverSavedContactsAfterProfileRecreation(
+ _ publicKeys: [String],
+ wallet: WalletViewModel,
+ requireImmediatePublication: Bool = false
+ ) async -> Error? {
+ let publicKeys = rememberSavedContacts(publicKeys, replacing: true)
+ guard !publicKeys.isEmpty else { return nil }
+ guard await canPublishPrivateEndpoints(wallet: wallet) else { return nil }
+
+ invalidateLinkEstablishmentWork()
+ guard await purgePrivatePaymentOutboxForProfileRecovery(reason: "profile recovery") else {
+ return handleProfileRecoveryPurgeFailure(requireImmediatePublication: requireImmediatePublication)
+ }
+
+ let startedAt = UInt64(Date().timeIntervalSince1970)
+ for publicKey in publicKeys {
+ if let linkId = activeHandlesByContact[publicKey]?.linkId {
+ try? await PubkyService.closeEncryptedLink(linkId: linkId)
+ }
+ if let handshakeId = activeHandlesByContact[publicKey]?.handshakeId {
+ try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId)
+ }
+
+ markContactForProfileRecovery(publicKey, startedAt: startedAt)
+ }
+
+ persistState(markWalletBackup: true)
+ Self.setProfileRecoveryPending(false)
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
- await publishLocalEndpoints(for: publicKeys, wallet: wallet, maxAdvanceSteps: 3, reason: "prepare")
+
+ return await publishLocalEndpoints(
+ for: publicKeys,
+ wallet: wallet,
+ maxAdvanceSteps: 3,
+ reason: "profile recovery",
+ forceLocalPublishWhenRemoteEmpty: true,
+ requireImmediatePublication: requireImmediatePublication
+ )
+ }
+
+ func handleProfileRecoveryPurgeFailure(requireImmediatePublication: Bool) -> Error? {
+ Self.setProfileRecoveryPending(true)
+ return requireImmediatePublication ? PrivatePaykitError.privateUnavailable : nil
+ }
+
+ func markContactForProfileRecovery(_ publicKey: String, startedAt: UInt64) {
+ activeHandlesByContact[publicKey] = ContactPaykitHandles()
+
+ var contactState = ContactState()
+ contactState.recoveryStartedAt = startedAt
+ state.contacts[publicKey] = contactState
+ cancelPendingPublicationRetry(for: publicKey)
}
func refreshSavedContactEndpoints(for publicKeys: [String], wallet: WalletViewModel) async {
let publicKeys = rememberSavedContacts(publicKeys, replacing: true)
guard await canPublishPrivateEndpoints(wallet: wallet) else { return }
+ if Self.isProfileRecoveryPending, !publicKeys.isEmpty {
+ await recoverSavedContactsAfterProfileRecreation(publicKeys, wallet: wallet)
+ return
+ }
await publishLocalEndpoints(for: publicKeys, wallet: wallet, maxAdvanceSteps: 1, reason: "refresh")
}
func refreshKnownSavedContactEndpoints(wallet: WalletViewModel, reason: String, forceRefreshLightning: Bool = false) async {
guard !knownSavedContactKeys.isEmpty else { return }
guard await canPublishPrivateEndpoints(wallet: wallet) else { return }
+ if Self.isProfileRecoveryPending {
+ await recoverSavedContactsAfterProfileRecreation(Array(knownSavedContactKeys), wallet: wallet)
+ return
+ }
await publishLocalEndpoints(
for: Array(knownSavedContactKeys),
wallet: wallet,
@@ -29,8 +110,11 @@ extension PrivatePaykitService {
}
func removePublishedEndpoints() async throws {
+ try await removePublishedEndpoints(for: Array(state.contacts.keys))
+ }
+
+ func removePublishedEndpoints(for publicKeys: [String]) async throws {
invalidateLinkEstablishmentWork()
- let publicKeys = Array(state.contacts.keys)
var firstError: Error?
for publicKey in publicKeys {
@@ -97,8 +181,14 @@ extension PrivatePaykitService {
guard UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) else { return }
do {
- try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false)
- try await removePublishedEndpoints()
+ if !UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) {
+ try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false)
+ }
+
+ let publicKeys = pendingPrivateEndpointRemovalKeys(savedPublicKeys: savedPublicKeys)
+ if !publicKeys.isEmpty {
+ try await removePublishedEndpoints(for: publicKeys)
+ }
await clearUnsavedContactState(savedPublicKeys: savedPublicKeys)
Self.setContactSharingCleanupPending(false)
} catch {
@@ -106,6 +196,15 @@ extension PrivatePaykitService {
}
}
+ func pendingPrivateEndpointRemovalKeys(savedPublicKeys publicKeys: [String]) -> [String] {
+ if !UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) {
+ return Array(state.contacts.keys)
+ }
+
+ let savedKeys = Set(normalizedSavedContactKeys(publicKeys))
+ return state.contacts.keys.filter { !savedKeys.contains($0) }
+ }
+
func clearUnsavedContactState(savedPublicKeys publicKeys: [String]) async {
let savedKeys = Set(normalizedSavedContactKeys(publicKeys))
let staleKeys = state.contacts.keys.filter { !savedKeys.contains($0) }
@@ -117,6 +216,7 @@ extension PrivatePaykitService {
await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(excludingPublicKeys: Array(savedKeys))
}
+ @discardableResult
func publishLocalEndpoints(
for publicKeys: [String],
wallet: WalletViewModel,
@@ -124,19 +224,27 @@ extension PrivatePaykitService {
reason: String,
scheduleRetries: Bool = true,
forceLocalPublishWhenRemoteEmpty: Bool = false,
- forceRefreshLightning: Bool = false
- ) async {
+ forceRefreshLightning: Bool = false,
+ requireImmediatePublication: Bool = false
+ ) async -> Error? {
let generation = stateGeneration
+ var firstError: Error?
+
for publicKey in publicKeys {
+ var retryPublicKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey
do {
guard let normalizedKey = knownSavedContact(publicKey) else {
continue
}
+ retryPublicKey = normalizedKey
guard let linkId = try await establishedLinkId(for: normalizedKey, maxAdvanceSteps: maxAdvanceSteps, generation: generation) else {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
}
+ if requireImmediatePublication, shouldRequirePrivateEndpointRemoval(publicKey: normalizedKey), firstError == nil {
+ firstError = PrivatePaykitError.privateUnavailable
+ }
continue
}
@@ -161,6 +269,9 @@ extension PrivatePaykitService {
do {
fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation)
} catch {
+ if requireImmediatePublication, firstError == nil {
+ firstError = error
+ }
if shouldCountAsStaleLinkFailure(error) {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
@@ -170,7 +281,15 @@ extension PrivatePaykitService {
throw error
}
- guard await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount) else {
+ let shouldForcePublish = forceLocalPublishWhenRemoteEmpty &&
+ fetchedCount == 0 &&
+ state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false
+ let shouldPublish = if shouldForcePublish {
+ true
+ } else {
+ await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount)
+ }
+ guard shouldPublish else {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
}
@@ -182,12 +301,15 @@ extension PrivatePaykitService {
linkId: linkId,
wallet: wallet,
generation: generation,
+ force: shouldForcePublish,
forceRefreshLightning: forceRefreshLightning
)
if fetchedCount == 0, state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
}
+ } else if scheduleRetries, await shouldRetryMissingPrivateLightningEndpoint(for: normalizedKey, wallet: wallet) {
+ schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
} else {
cancelPendingPublicationRetry(for: normalizedKey)
}
@@ -198,6 +320,9 @@ extension PrivatePaykitService {
do {
fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation)
} catch {
+ if requireImmediatePublication, firstError == nil {
+ firstError = error
+ }
if shouldCountAsStaleLinkFailure(error) {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
@@ -214,7 +339,7 @@ extension PrivatePaykitService {
continue
}
- // Recovery retries may need to send the same map again if restored Noise counters diverged.
+ // Recovery retries may need to resend the same map after a link is re-established and remote state is empty.
let shouldForcePublish = forceLocalPublishWhenRemoteEmpty &&
fetchedCount == 0 &&
state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false
@@ -230,16 +355,26 @@ extension PrivatePaykitService {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
}
+ } else if scheduleRetries, await shouldRetryMissingPrivateLightningEndpoint(for: normalizedKey, wallet: wallet) {
+ schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
} else {
cancelPendingPublicationRetry(for: normalizedKey)
}
} catch {
+ if scheduleRetries {
+ schedulePendingPublicationRetry(for: retryPublicKey, wallet: wallet)
+ }
+ if firstError == nil {
+ firstError = error
+ }
Logger.warn(
- "Failed to \(reason) private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)",
+ "Failed to \(reason) private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(retryPublicKey)): \(error)",
context: "PrivatePaykit"
)
}
}
+
+ return firstError
}
func schedulePendingPublicationRetry(
@@ -274,9 +409,13 @@ extension PrivatePaykitService {
)
let contactState = state.contacts[publicKey]
- let needsAnotherRetry = contactState?.linkCompletedAt == nil ||
+ let needsAnotherRetryFromLinkState = contactState?.linkCompletedAt == nil ||
contactState?.lastLocalPayloadHash == nil ||
contactState?.remoteEndpoints.isEmpty != false
+ var needsAnotherRetry = needsAnotherRetryFromLinkState
+ if !needsAnotherRetry {
+ needsAnotherRetry = await shouldRetryMissingPrivateLightningEndpoint(for: publicKey, wallet: wallet)
+ }
if needsAnotherRetry {
schedulePendingPublicationRetry(for: publicKey, wallet: wallet, remainingAttempts: remainingAttempts - 1)
}
@@ -361,7 +500,6 @@ extension PrivatePaykitService {
try await PubkyService.setPrivatePayments(linkId: linkId, entries: removalEntries)
try ensureCurrentGeneration(generation)
state.contacts[publicKey]?.lastLocalPayloadHash = nil
- state.contacts[publicKey]?.localInvoice = nil
try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation)
let ownPublicKey = await (PubkyService.currentPublicKey()).flatMap(PubkyPublicKeyFormat.normalized)
if let ownPublicKey {
diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift
index dbfff5f2d..0921af12e 100644
--- a/Bitkit/Services/PrivatePaykitService+Endpoints.swift
+++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift
@@ -22,7 +22,11 @@ extension PrivatePaykitService {
for publicKey in matchingContacts {
rememberReceivedInvoicePaymentHash(paymentHash, publicKey: publicKey)
+ if state.contacts[publicKey]?.localInvoice?.paymentHash == paymentHash {
+ state.contacts[publicKey]?.localInvoice = nil
+ }
}
+ persistState()
guard await canPublishPrivateEndpoints(wallet: wallet) else { return }
@@ -202,12 +206,27 @@ extension PrivatePaykitService {
isKnownSavedContact(publicKey)
else { return }
try ensureCurrentGeneration(generation)
- let endpoints = try await buildLocalEndpoints(
- for: publicKey,
- wallet: wallet,
- generation: generation,
- forceRefreshLightning: forceRefreshLightning
- )
+ let endpoints: [PublicPaykitService.Endpoint]
+ do {
+ endpoints = try await buildLocalEndpoints(
+ for: publicKey,
+ wallet: wallet,
+ generation: generation,
+ forceRefreshLightning: forceRefreshLightning
+ )
+ } catch let error as PrivatePaykitError {
+ guard case .privateUnavailable = error else {
+ throw error
+ }
+ try await publishLocalEndpointRemovalTombstone(
+ to: publicKey,
+ linkId: linkId,
+ wallet: wallet,
+ generation: generation,
+ force: force
+ )
+ return
+ }
try ensureCurrentGeneration(generation)
guard !endpoints.isEmpty else { return }
@@ -235,24 +254,60 @@ extension PrivatePaykitService {
persistState()
}
+ func publishLocalEndpointRemovalTombstone(
+ to publicKey: String,
+ linkId: String,
+ wallet: WalletViewModel,
+ generation: UInt64,
+ force: Bool
+ ) async throws {
+ guard shouldRequirePrivateEndpointRemoval(publicKey: publicKey) else { return }
+
+ let entries = privateEndpointRemovalEntries()
+ try validateNoisePayload(entries: entries)
+ let payloadHash = localPayloadHash(entries: entries)
+ guard force || state.contacts[publicKey]?.lastLocalPayloadHash != payloadHash else {
+ return
+ }
+
+ try ensureCurrentGeneration(generation)
+ guard await canPublishPrivateEndpoints(wallet: wallet),
+ isKnownSavedContact(publicKey)
+ else { return }
+
+ do {
+ try await PubkyService.setPrivatePayments(linkId: linkId, entries: entries)
+ try ensureCurrentGeneration(generation)
+ } catch {
+ await recordLinkFailure(publicKey: publicKey, error: error, generation: generation)
+ throw error
+ }
+
+ try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation)
+ state.contacts[publicKey, default: ContactState()].lastLocalPayloadHash = payloadHash
+ persistState()
+ }
+
func buildLocalEndpoints(for publicKey: String, wallet: WalletViewModel,
generation: UInt64, forceRefreshLightning: Bool = false) async throws -> [PublicPaykitService.Endpoint]
{
var endpoints: [PublicPaykitService.Endpoint] = []
- let reservedAddress = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress(for: publicKey)
- try ensureCurrentGeneration(generation)
- let onchainPayload = try PublicPaykitService.serializePayload(value: reservedAddress)
- endpoints.append(
- PublicPaykitService.Endpoint(
- methodId: PublicPaykitService.onchainMethodId(for: reservedAddress),
- value: reservedAddress,
- min: nil,
- max: nil,
- rawPayload: onchainPayload
+ if PublicPaykitService.isOnchainPaymentOptionEnabled() {
+ let reservedAddress = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress(for: publicKey)
+ try ensureCurrentGeneration(generation)
+ let onchainPayload = try PublicPaykitService.serializePayload(value: reservedAddress)
+ endpoints.append(
+ PublicPaykitService.Endpoint(
+ methodId: PublicPaykitService.onchainMethodId(for: reservedAddress),
+ value: reservedAddress,
+ min: nil,
+ max: nil,
+ rawPayload: onchainPayload
+ )
)
- )
+ }
- if await walletHasUsableChannels(wallet) {
+ if PublicPaykitService.isLightningPaymentOptionEnabled(), await walletHasUsableChannels(wallet) {
do {
let invoice = try await currentOrRotatedInvoice(
for: publicKey,
@@ -273,17 +328,20 @@ extension PrivatePaykitService {
)
} catch {
try ensureCurrentGeneration(generation)
- state.contacts[publicKey]?.localInvoice = nil
- persistState()
+ if let privateError = error as? PrivatePaykitError,
+ case .routeHintsUnavailable = privateError
+ {
+ schedulePendingPublicationRetry(for: publicKey, wallet: wallet)
+ }
Logger.warn(
"Failed to prepare private Paykit Lightning invoice for \(PubkyPublicKeyFormat.redacted(publicKey)); publishing on-chain only: \(error)",
context: "PrivatePaykit"
)
}
- } else {
- try ensureCurrentGeneration(generation)
- state.contacts[publicKey]?.localInvoice = nil
- persistState()
+ }
+
+ guard !endpoints.isEmpty else {
+ throw PrivatePaykitError.privateUnavailable
}
return endpoints
@@ -315,8 +373,6 @@ extension PrivatePaykitService {
}
try validateNoisePayload(entries: onchainOnlyEntries)
- state.contacts[publicKey]?.localInvoice = nil
- persistState()
Logger.warn(
"Private Paykit endpoint map is too large with Lightning invoice for \(PubkyPublicKeyFormat.redacted(publicKey)); publishing on-chain only.",
context: "PrivatePaykit"
diff --git a/Bitkit/Services/PrivatePaykitService+Errors.swift b/Bitkit/Services/PrivatePaykitService+Errors.swift
index 217888225..fd9cfbf47 100644
--- a/Bitkit/Services/PrivatePaykitService+Errors.swift
+++ b/Bitkit/Services/PrivatePaykitService+Errors.swift
@@ -6,6 +6,7 @@ enum PrivatePaykitError: LocalizedError {
case privateUnavailable
case payloadTooLarge
case staleLinkState
+ case routeHintsUnavailable
var errorDescription: String? {
switch self {
@@ -15,6 +16,8 @@ enum PrivatePaykitError: LocalizedError {
"The private Paykit payload is too large."
case .staleLinkState:
"The private Paykit link state changed."
+ case .routeHintsUnavailable:
+ "A reachable private Lightning endpoint is not available yet."
}
}
}
@@ -73,8 +76,6 @@ extension PrivatePaykitService {
"decrypt",
"decryption",
"cipher",
- "noise state",
- "counter",
"invalid tag",
"bad mac",
].contains { lowercasedReason.contains($0) }
diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift
index d493263b1..ab6fae68f 100644
--- a/Bitkit/Services/PrivatePaykitService+Invoices.swift
+++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift
@@ -22,6 +22,9 @@ extension PrivatePaykitService {
guard case let .lightning(decodedInvoice) = try await decode(invoice: bolt11) else {
throw PublicPaykitError.invalidPayload
}
+ guard PublicPaykitService.hasLightningRouteHints(bolt11: bolt11) else {
+ throw PrivatePaykitError.routeHintsUnavailable
+ }
let invoice = StoredInvoice(
bolt11: bolt11,
@@ -55,7 +58,8 @@ extension PrivatePaykitService {
await !isReceivedInvoiceSettled(paymentHash: invoice.paymentHash),
case let .lightning(decodedInvoice) = try? await decode(invoice: invoice.bolt11),
!decodedInvoice.isExpired,
- decodedInvoice.amountSatoshis == 0
+ decodedInvoice.amountSatoshis == 0,
+ PublicPaykitService.hasLightningRouteHints(bolt11: invoice.bolt11)
else {
return nil
}
@@ -80,12 +84,28 @@ extension PrivatePaykitService {
wallet.hasUsableChannels
}
+ func shouldRetryMissingPrivateLightningEndpoint(for publicKey: String, wallet: WalletViewModel) async -> Bool {
+ guard PublicPaykitService.isLightningPaymentOptionEnabled(),
+ await walletHasUsableChannels(wallet)
+ else {
+ return false
+ }
+
+ return await reusablePrivateInvoice(for: publicKey) == nil
+ }
+
@MainActor
- func canPublishPrivateEndpoints(wallet: WalletViewModel) -> Bool {
- UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) &&
- UIApplication.shared.applicationState == .active &&
- wallet.walletExists == true &&
- wallet.nodeLifecycleState == .running
+ func canPublishPrivateEndpoints(wallet: WalletViewModel) async -> Bool {
+ guard UserDefaults.standard.bool(forKey: Self.publishingEnabledKey),
+ UIApplication.shared.applicationState == .active,
+ wallet.walletExists == true,
+ wallet.nodeLifecycleState == .running,
+ let ownPublicKey = await PubkyService.currentPublicKey()
+ else {
+ return false
+ }
+
+ return PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey)
}
@MainActor
diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift
index dd716963f..587d3c65e 100644
--- a/Bitkit/Services/PrivatePaykitService+Payments.swift
+++ b/Bitkit/Services/PrivatePaykitService+Payments.swift
@@ -111,6 +111,11 @@ extension PrivatePaykitService {
guard let normalizedKey = knownSavedContact(publicKey) else {
return try await PublicPaykitService.beginPayment(to: publicKey)
}
+ guard let ownPublicKey = await PubkyService.currentPublicKey(),
+ PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey)
+ else {
+ return try await PublicPaykitService.beginPayment(to: publicKey)
+ }
do {
let privateResult = try await beginPrivatePayment(
@@ -207,6 +212,15 @@ extension PrivatePaykitService {
continue
}
+ guard PublicPaykitService.hasLightningRouteHints(bolt11: endpoint.value) else {
+ staleLightningPaymentHashes.insert(paymentHash)
+ Logger.warn(
+ "Ignoring private Paykit Lightning endpoint without route hints from \(PubkyPublicKeyFormat.redacted(publicKey))",
+ context: "PrivatePaykit"
+ )
+ continue
+ }
+
if await hasAttemptedOutboundBolt11Payment(paymentHash: paymentHash) {
staleLightningPaymentHashes.insert(paymentHash)
Logger.warn(
@@ -286,8 +300,7 @@ extension PrivatePaykitService {
guard !remoteEntries.isEmpty else {
// Paykit returns an empty map when there are no unread private-payment messages.
- // Keep the cached map in that case; the current rc5 API cannot distinguish
- // "no unread update" from a peer intentionally publishing an empty map.
+ // Keep the cached map in that case so transient empty reads do not drop the last known endpoints.
return 0
}
diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift
index bfb91bd23..b12d31701 100644
--- a/Bitkit/Services/PrivatePaykitService+State.swift
+++ b/Bitkit/Services/PrivatePaykitService+State.swift
@@ -3,7 +3,15 @@ import Foundation
// MARK: - Active Paykit Handles
extension PrivatePaykitService {
- func closeAndClear() async {
+ func markProfileRecoveryPendingIfNeeded() {
+ guard !state.contacts.isEmpty else { return }
+ Self.setProfileRecoveryPending(true)
+ }
+
+ func closeAndClear(markProfileRecoveryPending: Bool = false) async {
+ if markProfileRecoveryPending {
+ markProfileRecoveryPendingIfNeeded()
+ }
resetInFlightWork()
await closeActivePaykitHandles()
activeHandlesByContact.removeAll()
@@ -195,6 +203,16 @@ extension PrivatePaykitService {
return true
}
+ return await purgePrivatePaymentStorage(reason: reason)
+ }
+
+ @discardableResult
+ func purgePrivatePaymentOutboxForProfileRecovery(reason: String) async -> Bool {
+ await purgePrivatePaymentStorage(reason: reason)
+ }
+
+ @discardableResult
+ private func purgePrivatePaymentStorage(reason: String) async -> Bool {
guard let sessionSecret = try? Keychain.loadString(key: .paykitSession),
!sessionSecret.isEmpty
else { return false }
diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift
index 1ddcd1e5f..ff62ba16b 100644
--- a/Bitkit/Services/PrivatePaykitService.swift
+++ b/Bitkit/Services/PrivatePaykitService.swift
@@ -16,8 +16,9 @@ actor PrivatePaykitService {
static let invoiceRefreshBufferSeconds: TimeInterval = 30 * 60
static let maxReceivedInvoicePaymentHashesPerContact = 100
static let staleLinkFailureThreshold = 3
- static let publishingEnabledKey = "sharesPublicPaykitEndpoints"
+ static let publishingEnabledKey = "sharesPrivatePaykitEndpoints"
static let cleanupPendingKey = "paykitContactSharingCleanupPending"
+ static let profileRecoveryPendingKey = "privatePaykitProfileRecoveryPending"
static let cacheStateKey = "privatePaykitCacheState"
static let privateEndpointRemovalPayload = #"{"value":""}"#
static let recoveryMarkerStageInit = "init"
@@ -57,4 +58,12 @@ actor PrivatePaykitService {
static func setContactSharingCleanupPending(_ isPending: Bool) {
UserDefaults.standard.set(isPending, forKey: cleanupPendingKey)
}
+
+ static func setProfileRecoveryPending(_ isPending: Bool) {
+ UserDefaults.standard.set(isPending, forKey: profileRecoveryPendingKey)
+ }
+
+ static var isProfileRecoveryPending: Bool {
+ UserDefaults.standard.bool(forKey: profileRecoveryPendingKey)
+ }
}
diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift
index dd1b54d5e..97d94e998 100644
--- a/Bitkit/Services/PublicPaykitService.swift
+++ b/Bitkit/Services/PublicPaykitService.swift
@@ -6,6 +6,7 @@ enum PublicPaykitError: LocalizedError {
case noSupportedEndpoint
case walletNotReady
case invalidPayload
+ case routeHintsUnavailable
var errorDescription: String? {
switch self {
@@ -15,6 +16,8 @@ enum PublicPaykitError: LocalizedError {
return "Bitkit could not prepare a public payment endpoint because the wallet is not ready."
case .invalidPayload:
return "The public payment endpoint payload is invalid."
+ case .routeHintsUnavailable:
+ return "A reachable Lightning payment endpoint is not available yet."
}
}
}
@@ -69,6 +72,10 @@ private actor PublicPaykitEndpointLock {
enum PublicPaykitService {
private static let endpointLock = PublicPaykitEndpointLock()
+ static let publishingEnabledKey = "sharesPublicPaykitEndpoints"
+ static let lightningPaymentOptionEnabledKey = "paykitPaymentOptionLightningEnabled"
+ static let onchainPaymentOptionEnabledKey = "paykitPaymentOptionOnchainEnabled"
+
enum MethodId: String, Hashable, CaseIterable {
case bitcoinLightningBolt11 = "btc-lightning-bolt11"
case bitcoinLightningLnurl = "btc-lightning-lnurl"
@@ -233,13 +240,13 @@ enum PublicPaykitService {
return
}
- let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true)
+ let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true)
try await applyPublishedEndpoints(desiredEndpoints)
}
@MainActor
static func syncCurrentPublishedEndpoints(wallet: WalletViewModel) async throws {
- let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false)
+ let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false)
try await applyPublishedEndpoints(desiredEndpoints)
}
@@ -323,6 +330,22 @@ enum PublicPaykitService {
MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) }
}
+ static func isLightningPaymentOptionEnabled(defaults: UserDefaults = .standard) -> Bool {
+ defaults.object(forKey: lightningPaymentOptionEnabledKey) as? Bool ?? true
+ }
+
+ static func isOnchainPaymentOptionEnabled(defaults: UserDefaults = .standard) -> Bool {
+ defaults.object(forKey: onchainPaymentOptionEnabledKey) as? Bool ?? true
+ }
+
+ static func hasLightningRouteHints(bolt11: String) -> Bool {
+ guard let invoice = try? Bolt11Invoice.fromStr(invoiceStr: bolt11) else {
+ return false
+ }
+
+ return invoice.routeHints().contains { !$0.isEmpty }
+ }
+
static func publishedEndpointSyncPlan(existingEndpoints: [MethodId: String], desiredEndpoints: [Endpoint]) -> EndpointSyncPlan {
let desiredMethodIds = Set(desiredEndpoints.map(\.methodId))
return EndpointSyncPlan(
@@ -400,7 +423,10 @@ enum PublicPaykitService {
}
@MainActor
- private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool) async throws -> [Endpoint] {
+ private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool, requireEndpoint: Bool) async throws -> [Endpoint] {
+ let includeOnchain = isOnchainPaymentOptionEnabled()
+ let includeLightning = isLightningPaymentOptionEnabled()
+
if refreshIfNeeded {
let isNodeReady = await wallet.waitForNodeToRun()
let lifecycleState = wallet.nodeLifecycleState
@@ -408,14 +434,22 @@ enum PublicPaykitService {
throw PublicPaykitError.walletNotReady
}
- _ = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: true)
+ _ = try await wallet.refreshPublicPaykitEndpoints(
+ forceRefreshBolt11: includeLightning,
+ includeOnchain: includeOnchain,
+ includeLightning: includeLightning
+ )
}
- let publicEndpoints = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: false)
+ let publicEndpoints = try await wallet.refreshPublicPaykitEndpoints(
+ forceRefreshBolt11: false,
+ includeOnchain: includeOnchain,
+ includeLightning: includeLightning
+ )
var endpoints: [Endpoint] = []
let onchainAddress = publicEndpoints.onchainAddress.trimmingCharacters(in: .whitespacesAndNewlines)
- if !onchainAddress.isEmpty {
+ if includeOnchain, !onchainAddress.isEmpty {
try endpoints.append(
Endpoint(
methodId: onchainMethodId(for: onchainAddress),
@@ -428,7 +462,7 @@ enum PublicPaykitService {
}
let bolt11 = publicEndpoints.bolt11.trimmingCharacters(in: .whitespacesAndNewlines)
- if !bolt11.isEmpty {
+ if includeLightning, !bolt11.isEmpty {
try endpoints.append(
Endpoint(
methodId: .bitcoinLightningBolt11,
@@ -440,7 +474,7 @@ enum PublicPaykitService {
)
}
- guard !endpoints.isEmpty else {
+ guard !endpoints.isEmpty || !requireEndpoint else {
throw PublicPaykitError.noSupportedEndpoint
}
diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift
index eab398541..0d95b2273 100644
--- a/Bitkit/ViewModels/ActivityListViewModel.swift
+++ b/Bitkit/ViewModels/ActivityListViewModel.swift
@@ -276,7 +276,7 @@ class ActivityListViewModel: ObservableObject {
try await coreService.activity.get(contact: publicKey, sortDirection: .desc)
}
- func setContact(_ contactPublicKey: String, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws {
+ func setContact(_ contactPublicKey: String?, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws {
if syncLdkPayments {
try? await syncLdkNodePayments()
}
diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift
index d293a33a7..b7670abcc 100644
--- a/Bitkit/ViewModels/NavigationViewModel.swift
+++ b/Bitkit/ViewModels/NavigationViewModel.swift
@@ -13,6 +13,7 @@ enum Route: Hashable {
case contactsIntro
case contactDetail(publicKey: String)
case contactActivity(publicKey: String)
+ case assignActivityContact(activityId: String)
case contactImportOverview
case contactImportSelect
case addContact(publicKey: String)
@@ -76,6 +77,7 @@ enum Route: Hashable {
case quickpayIntro
case notifications
case notificationsIntro
+ case paymentPreference
// Security
case dataBackups
diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift
index b59fa7575..afd49b015 100644
--- a/Bitkit/ViewModels/SettingsViewModel.swift
+++ b/Bitkit/ViewModels/SettingsViewModel.swift
@@ -204,6 +204,11 @@ class SettingsViewModel: NSObject, ObservableObject {
quickpayAmount = 5
enableNotifications = false
enableNotificationsAmount = false
+ UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey)
+ UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey)
+ UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints")
+ UserDefaults.standard.set(true, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey)
+ UserDefaults.standard.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey)
ignoresSwitchUnitToast = false
ignoresHideBalanceToast = false
pinFailedAttempts = 0
diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift
index 47429cdfa..58aec99f5 100644
--- a/Bitkit/ViewModels/WalletViewModel.swift
+++ b/Bitkit/ViewModels/WalletViewModel.swift
@@ -59,7 +59,7 @@ class WalletViewModel: ObservableObject {
private var probeOutcomes: [PaymentId: ProbeOutcome] = [:]
@AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false
- @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false
+ @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false
private static let publicPaykitInvoiceRefreshBufferSeconds: TimeInterval = 30 * 60
private static let paykitChannelUsabilityRefreshDelay: UInt64 = 5_000_000_000
@@ -1004,20 +1004,34 @@ class WalletViewModel: ObservableObject {
return onchainAddress
}
- func refreshPublicPaykitEndpoints(forceRefreshBolt11: Bool = false) async throws -> (onchainAddress: String, bolt11: String) {
- let publicOnchainAddress = try await refreshReusableOnchainAddress()
+ func refreshPublicPaykitEndpoints(
+ forceRefreshBolt11: Bool = false,
+ includeOnchain: Bool = true,
+ includeLightning: Bool = true
+ ) async throws -> (onchainAddress: String, bolt11: String) {
+ let publicOnchainAddress = includeOnchain ? try await refreshReusableOnchainAddress() : ""
- if hasUsableChannels {
+ if includeLightning, hasUsableChannels {
let hasReusableInvoice = await hasReusablePublicPaykitInvoice()
let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice
if shouldRefreshBolt11 {
- try await refreshPublicPaykitBolt11()
+ do {
+ try await refreshPublicPaykitBolt11()
+ } catch let error as PublicPaykitError {
+ guard case .routeHintsUnavailable = error else {
+ throw error
+ }
+ Logger.warn(
+ "Public Paykit Lightning invoice has no route hints yet; publishing without Lightning for now",
+ context: "WalletViewModel"
+ )
+ }
}
- } else {
+ } else if includeLightning {
clearPublicPaykitBolt11()
}
- return (publicOnchainAddress, publicPaykitBolt11)
+ return (publicOnchainAddress, includeLightning ? publicPaykitBolt11 : "")
}
func refreshPublicPaykitEndpointsOnForeground() async {
@@ -1042,8 +1056,16 @@ class WalletViewModel: ObservableObject {
await refreshAndSyncState()
try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning)
- if sharesPublicPaykitEndpoints, hasUsableChannels {
- await syncPublicPaykitEndpointsAfterChannelBecameUsable()
+ if sharesPublicPaykitEndpoints {
+ do {
+ if hasUsableChannels {
+ await syncPublicPaykitEndpointsAfterChannelBecameUsable()
+ } else {
+ try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self)
+ }
+ } catch {
+ Logger.warn("Failed to refresh public Paykit endpoints after channel availability changed: \(error)", context: "WalletViewModel")
+ }
}
await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints(
@@ -1058,7 +1080,10 @@ class WalletViewModel: ObservableObject {
guard publicPaykitBolt11ExpiresAt > Date().timeIntervalSince1970 + Self.publicPaykitInvoiceRefreshBufferSeconds else { return false }
guard case let .lightning(lightningInvoice) = try? await decode(invoice: publicPaykitBolt11) else { return false }
- return !lightningInvoice.isExpired && lightningInvoice.amountSatoshis == 0 && (lightningInvoice.description ?? "").isEmpty
+ return !lightningInvoice.isExpired &&
+ lightningInvoice.amountSatoshis == 0 &&
+ (lightningInvoice.description ?? "").isEmpty &&
+ PublicPaykitService.hasLightningRouteHints(bolt11: publicPaykitBolt11)
}
private func refreshPublicPaykitBolt11() async throws {
@@ -1067,6 +1092,10 @@ class WalletViewModel: ObservableObject {
clearPublicPaykitBolt11()
throw PublicPaykitError.invalidPayload
}
+ guard PublicPaykitService.hasLightningRouteHints(bolt11: invoice) else {
+ clearPublicPaykitBolt11()
+ throw PublicPaykitError.routeHintsUnavailable
+ }
publicPaykitBolt11 = invoice
publicPaykitBolt11PaymentHash = lightningInvoice.paymentHash.hex
diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift
index 8fc403e45..5fc567790 100644
--- a/Bitkit/Views/Contacts/AddContactView.swift
+++ b/Bitkit/Views/Contacts/AddContactView.swift
@@ -271,14 +271,7 @@ struct AddContactView: View {
switch result {
case let .opened(paymentRequest):
- guard await openContactPayment(paymentRequest: paymentRequest, publicKey: normalizedPublicKey) else {
- app.toast(
- type: .warning,
- title: t("slashtags__error_pay_title"),
- description: t("slashtags__error_pay_not_opened_msg")
- )
- return
- }
+ _ = await openContactPayment(paymentRequest: paymentRequest, publicKey: normalizedPublicKey)
case .noEndpoint, .notOpened:
if let messageKey = result.contactPaymentFailureMessageKey {
app.toast(
@@ -304,6 +297,11 @@ struct AddContactView: View {
try await app.handleScannedData(paymentRequest)
} catch {
Logger.warn("Failed to decode contact payment request: \(error)", context: "AddContactView")
+ app.toast(
+ type: .warning,
+ title: t("slashtags__error_pay_title"),
+ description: t("slashtags__error_pay_not_opened_msg")
+ )
return false
}
diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift
index 78f47995d..22bb52f68 100644
--- a/Bitkit/Views/Contacts/ContactActivityView.swift
+++ b/Bitkit/Views/Contacts/ContactActivityView.swift
@@ -64,7 +64,8 @@ struct ContactActivityView: View {
ActivityRow(
item: activity,
feeEstimates: feeEstimatesManager.estimates,
- contact: activityContact
+ contact: activityContact,
+ showContactAvatar: false
)
}
.accessibilityIdentifier("ContactActivity-\(index)")
diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift
index aa6668d1f..f98c66b1c 100644
--- a/Bitkit/Views/Contacts/ContactDetailView.swift
+++ b/Bitkit/Views/Contacts/ContactDetailView.swift
@@ -271,14 +271,7 @@ struct ContactDetailView: View {
switch result {
case let .opened(paymentRequest):
- guard await openContactPayment(paymentRequest: paymentRequest) else {
- app.toast(
- type: .warning,
- title: t("slashtags__error_pay_title"),
- description: t("slashtags__error_pay_not_opened_msg")
- )
- return
- }
+ _ = await openContactPayment(paymentRequest: paymentRequest)
case .noEndpoint, .notOpened:
if let messageKey = result.contactPaymentFailureMessageKey {
app.toast(
@@ -304,6 +297,11 @@ struct ContactDetailView: View {
try await app.handleScannedData(paymentRequest)
} catch {
Logger.warn("Failed to decode contact payment request: \(error)", context: "ContactDetailView")
+ app.toast(
+ type: .warning,
+ title: t("slashtags__error_pay_title"),
+ description: t("slashtags__error_pay_not_opened_msg")
+ )
return false
}
diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift
index cc3d495fd..1f09f68df 100644
--- a/Bitkit/Views/Profile/EditProfileView.swift
+++ b/Bitkit/Views/Profile/EditProfileView.swift
@@ -86,7 +86,6 @@ struct EditProfileView: View {
// MARK: - Avatar Picker
- @ViewBuilder
private var avatarPicker: some View {
PhotosPicker(selection: $selectedPhotoItem, matching: .images) {
avatarContent
@@ -176,6 +175,7 @@ struct EditProfileView: View {
}
private func performDeleteProfile() async throws {
+ await PrivatePaykitService.shared.markProfileRecoveryPendingIfNeeded()
try await contactsManager.deleteAllContacts()
try await pubkyProfile.deleteProfile()
navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro]
diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift
index 1653d9cbd..683d91ae0 100644
--- a/Bitkit/Views/Profile/PayContactsView.swift
+++ b/Bitkit/Views/Profile/PayContactsView.swift
@@ -2,11 +2,13 @@ import SwiftUI
struct PayContactsView: View {
@AppStorage("hasConfirmedPublicPaykitEndpoints") private var hasConfirmedPublicPaykitEndpoints = false
- @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false
+ @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false
+ @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var contactsManager: ContactsManager
@EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var pubkyProfile: PubkyProfileManager
@EnvironmentObject var wallet: WalletViewModel
@State private var enablePayments = true
@@ -62,7 +64,7 @@ struct PayContactsView: View {
.background(Color.customBlack)
.navigationBarHidden(true)
.task {
- enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true
+ enablePayments = hasConfirmedPublicPaykitEndpoints ? (sharesPrivatePaykitEndpoints || sharesPublicPaykitEndpoints) : true
}
}
@@ -74,15 +76,20 @@ struct PayContactsView: View {
do {
if publish {
try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true)
+ let canUsePrivateContactPayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile
+ sharesPrivatePaykitEndpoints = canUsePrivateContactPayments
sharesPublicPaykitEndpoints = true
hasConfirmedPublicPaykitEndpoints = true
- PrivatePaykitService.setContactSharingCleanupPending(false)
- await PrivatePaykitService.shared.prepareSavedContacts(
- contactsManager.contacts.map(\.publicKey),
- wallet: wallet
- )
+ if canUsePrivateContactPayments {
+ PrivatePaykitService.setContactSharingCleanupPending(false)
+ await PrivatePaykitService.shared.prepareSavedContacts(
+ contactsManager.contacts.map(\.publicKey),
+ wallet: wallet
+ )
+ }
} else {
var cleanupError: Error?
+ sharesPrivatePaykitEndpoints = false
sharesPublicPaykitEndpoints = false
hasConfirmedPublicPaykitEndpoints = true
do {
@@ -107,7 +114,7 @@ struct PayContactsView: View {
}
navigation.path = [.profile]
} catch {
- enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true
+ enablePayments = hasConfirmedPublicPaykitEndpoints ? (sharesPrivatePaykitEndpoints || sharesPublicPaykitEndpoints) : true
Logger.error("Failed to sync public payment endpoints: \(error)", context: "PayContactsView")
app.toast(
type: .error,
@@ -124,6 +131,7 @@ struct PayContactsView: View {
.environmentObject(AppViewModel())
.environmentObject(ContactsManager())
.environmentObject(NavigationViewModel())
+ .environmentObject(PubkyProfileManager())
.environmentObject(WalletViewModel())
}
.preferredColorScheme(.dark)
diff --git a/Bitkit/Views/Settings/General/PaymentPreferenceView.swift b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift
new file mode 100644
index 000000000..fb1bb8e61
--- /dev/null
+++ b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift
@@ -0,0 +1,267 @@
+import SwiftUI
+
+struct PaymentPreferenceView: View {
+ @AppStorage("hasConfirmedPublicPaykitEndpoints") private var hasConfirmedPublicPaykitEndpoints = false
+ @AppStorage(PublicPaykitService.lightningPaymentOptionEnabledKey) private var lightningPaymentOptionEnabled = true
+ @AppStorage(PublicPaykitService.onchainPaymentOptionEnabledKey) private var onchainPaymentOptionEnabled = true
+ @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false
+ @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false
+
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var contactsManager: ContactsManager
+ @EnvironmentObject private var pubkyProfile: PubkyProfileManager
+ @EnvironmentObject private var wallet: WalletViewModel
+
+ @State private var isUpdatingPaymentOptions = false
+ @State private var isUpdatingPrivate = false
+ @State private var isUpdatingPublic = false
+
+ private var lightningOptionToggle: Binding {
+ Binding(
+ get: { lightningPaymentOptionEnabled },
+ set: { value in
+ Task { await updatePaymentOption(.lightning, enabled: value) }
+ }
+ )
+ }
+
+ private var onchainOptionToggle: Binding {
+ Binding(
+ get: { onchainPaymentOptionEnabled },
+ set: { value in
+ Task { await updatePaymentOption(.onchain, enabled: value) }
+ }
+ )
+ }
+
+ private var privateToggle: Binding {
+ Binding(
+ get: { sharesPrivatePaykitEndpoints },
+ set: { value in
+ Task { await updatePrivateSharing(value) }
+ }
+ )
+ }
+
+ private var publicToggle: Binding {
+ Binding(
+ get: { sharesPublicPaykitEndpoints },
+ set: { value in
+ Task { await updatePublicSharing(value) }
+ }
+ )
+ }
+
+ private var canUsePrivateContactPayments: Bool {
+ pubkyProfile.hasLocalSecretKeyForCurrentProfile
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ NavigationBar(title: t("settings__adv__payment_preference"))
+ .padding(.horizontal, 16)
+
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 0) {
+ BodyMText(t("settings__adv__pp_header"), textColor: .white64)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 32)
+ .padding(.bottom, 16)
+
+ SettingsSectionHeader(t("settings__adv__pp_options").localizedUppercase)
+
+ SettingsRow(
+ title: t("settings__adv__pp_lightning"),
+ rightIcon: nil,
+ toggle: lightningOptionToggle,
+ disabled: isUpdatingPaymentOptions || (lightningPaymentOptionEnabled && !onchainPaymentOptionEnabled),
+ testIdentifier: "LightningPaymentOptionToggle"
+ )
+
+ SettingsRow(
+ title: t("settings__adv__pp_onchain"),
+ rightIcon: nil,
+ toggle: onchainOptionToggle,
+ disabled: isUpdatingPaymentOptions || (onchainPaymentOptionEnabled && !lightningPaymentOptionEnabled),
+ testIdentifier: "OnchainPaymentOptionToggle"
+ )
+
+ if pubkyProfile.isAuthenticated {
+ SettingsSectionHeader(t("settings__adv__pp_contacts").localizedUppercase)
+ .padding(.top, 16)
+
+ if canUsePrivateContactPayments {
+ SettingsRow(
+ title: t("settings__adv__pp_private_contacts"),
+ rightIcon: nil,
+ toggle: privateToggle,
+ disabled: isUpdatingPrivate,
+ testIdentifier: "PrivateContactPaymentsToggle"
+ )
+ }
+
+ SettingsRow(
+ title: t("settings__adv__pp_public_contacts"),
+ rightIcon: nil,
+ toggle: publicToggle,
+ disabled: isUpdatingPublic,
+ testIdentifier: "PublicContactPaymentsToggle"
+ )
+ }
+
+ Spacer(minLength: 220)
+
+ if pubkyProfile.isAuthenticated {
+ BodySText(t("settings__adv__pp_public_footer"), textColor: .white64)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .padding(.horizontal, 16)
+ .bottomSafeAreaPadding()
+ }
+ }
+ .background(Color.customBlack)
+ .navigationBarHidden(true)
+ .task(id: canUsePrivateContactPayments) {
+ await reconcilePrivateSharingAvailability()
+ }
+ }
+
+ private func updatePrivateSharing(_ enabled: Bool) async {
+ guard !isUpdatingPrivate else { return }
+ guard !enabled || pubkyProfile.isAuthenticated else {
+ showProfileRequiredError()
+ return
+ }
+ guard !enabled || canUsePrivateContactPayments else {
+ sharesPrivatePaykitEndpoints = false
+ showProfileRequiredError()
+ return
+ }
+ isUpdatingPrivate = true
+ sharesPrivatePaykitEndpoints = enabled
+ hasConfirmedPublicPaykitEndpoints = true
+ defer { isUpdatingPrivate = false }
+
+ if enabled {
+ PrivatePaykitService.setContactSharingCleanupPending(false)
+ await PrivatePaykitService.shared.prepareSavedContacts(
+ contactsManager.contacts.map(\.publicKey),
+ wallet: wallet
+ )
+ } else {
+ do {
+ try await PrivatePaykitService.shared.removePublishedEndpoints()
+ PrivatePaykitService.setContactSharingCleanupPending(false)
+ } catch {
+ PrivatePaykitService.setContactSharingCleanupPending(true)
+ Logger.warn("Deferred private contact payment cleanup after disable failed: \(error)", context: "PaymentPreferenceView")
+ }
+ }
+ }
+
+ private func updatePublicSharing(_ enabled: Bool) async {
+ guard !isUpdatingPublic else { return }
+ guard !enabled || pubkyProfile.isAuthenticated else {
+ showProfileRequiredError()
+ return
+ }
+ isUpdatingPublic = true
+ let previousValue = sharesPublicPaykitEndpoints
+ sharesPublicPaykitEndpoints = enabled
+ hasConfirmedPublicPaykitEndpoints = true
+ defer { isUpdatingPublic = false }
+
+ do {
+ try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: enabled)
+ } catch {
+ sharesPublicPaykitEndpoints = previousValue
+ Logger.error("Failed to update public contact payments: \(error)", context: "PaymentPreferenceView")
+ app.toast(type: .error, title: t("common__error"), description: error.localizedDescription)
+ }
+ }
+
+ private func reconcilePrivateSharingAvailability() async {
+ guard sharesPrivatePaykitEndpoints, !canUsePrivateContactPayments else { return }
+ sharesPrivatePaykitEndpoints = false
+ }
+
+ private enum PaymentOption {
+ case lightning
+ case onchain
+ }
+
+ private func updatePaymentOption(_ option: PaymentOption, enabled: Bool) async {
+ guard !isUpdatingPaymentOptions else { return }
+ guard enabled || canDisablePaymentOption(option) else { return }
+
+ isUpdatingPaymentOptions = true
+ let previousLightning = lightningPaymentOptionEnabled
+ let previousOnchain = onchainPaymentOptionEnabled
+
+ switch option {
+ case .lightning:
+ lightningPaymentOptionEnabled = enabled
+ case .onchain:
+ onchainPaymentOptionEnabled = enabled
+ }
+
+ defer { isUpdatingPaymentOptions = false }
+
+ do {
+ try await refreshPublishedPaymentOptions()
+ } catch {
+ lightningPaymentOptionEnabled = previousLightning
+ onchainPaymentOptionEnabled = previousOnchain
+ await refreshPublishedPaymentOptionsBestEffort()
+ Logger.error("Failed to update payment options: \(error)", context: "PaymentPreferenceView")
+ app.toast(type: .error, title: t("common__error"), description: error.localizedDescription)
+ }
+ }
+
+ private func canDisablePaymentOption(_ option: PaymentOption) -> Bool {
+ switch option {
+ case .lightning:
+ return onchainPaymentOptionEnabled
+ case .onchain:
+ return lightningPaymentOptionEnabled
+ }
+ }
+
+ private func showProfileRequiredError() {
+ app.toast(type: .error, title: t("common__error"), description: t("profile__session_expired_description"))
+ }
+
+ private func refreshPublishedPaymentOptions() async throws {
+ if sharesPublicPaykitEndpoints {
+ try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true)
+ }
+
+ if sharesPrivatePaykitEndpoints, canUsePrivateContactPayments {
+ if let privatePublishError = await PrivatePaykitService.shared.prepareSavedContacts(
+ contactsManager.contacts.map(\.publicKey),
+ wallet: wallet,
+ requireImmediatePublication: true
+ ) {
+ throw privatePublishError
+ }
+ }
+ }
+
+ private func refreshPublishedPaymentOptionsBestEffort() async {
+ if sharesPublicPaykitEndpoints {
+ do {
+ try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true)
+ } catch {
+ Logger.warn("Failed to restore public payment options after preference rollback: \(error)", context: "PaymentPreferenceView")
+ }
+ }
+
+ if sharesPrivatePaykitEndpoints, canUsePrivateContactPayments {
+ await PrivatePaykitService.shared.prepareSavedContacts(
+ contactsManager.contacts.map(\.publicKey),
+ wallet: wallet
+ )
+ }
+ }
+}
diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift
index 413f970c9..92727e6df 100644
--- a/Bitkit/Views/Settings/GeneralSettingsView.swift
+++ b/Bitkit/Views/Settings/GeneralSettingsView.swift
@@ -1,8 +1,12 @@
import SwiftUI
struct GeneralSettingsView: View {
+ @AppStorage(PublicPaykitService.lightningPaymentOptionEnabledKey) private var lightningPaymentOptionEnabled = true
+ @AppStorage(PublicPaykitService.onchainPaymentOptionEnabledKey) private var onchainPaymentOptionEnabled = true
+
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var currency: CurrencyViewModel
+ @EnvironmentObject var pubkyProfile: PubkyProfileManager
@EnvironmentObject var settings: SettingsViewModel
@EnvironmentObject var tagManager: TagManager
@StateObject private var languageManager = LanguageManager.shared
@@ -73,6 +77,17 @@ struct GeneralSettingsView: View {
.accessibilityElement(children: .contain)
.accessibilityIdentifier("TransactionSpeedSettings")
+ if pubkyProfile.isAuthenticated {
+ NavigationLink(value: Route.paymentPreference) {
+ SettingsRow(
+ title: t("settings__adv__payment_preference"),
+ iconName: "list-dashes",
+ rightText: paymentPreferenceSummary
+ )
+ }
+ .accessibilityIdentifier("PaymentPreferenceSettings")
+ }
+
NavigationLink(value: app.hasSeenQuickpayIntro ? Route.quickpay : Route.quickpayIntro) {
SettingsRow(
title: t("settings__quickpay__nav_title"),
@@ -96,6 +111,19 @@ struct GeneralSettingsView: View {
.bottomSafeAreaPadding()
}
}
+
+ private var paymentPreferenceSummary: String {
+ switch (lightningPaymentOptionEnabled, onchainPaymentOptionEnabled) {
+ case (true, true):
+ t("settings__adv__pp_both")
+ case (true, false):
+ t("settings__adv__pp_lightning_short")
+ case (false, true):
+ t("settings__adv__pp_onchain_short")
+ case (false, false):
+ t("common__off")
+ }
+ }
}
#Preview {
@@ -103,6 +131,7 @@ struct GeneralSettingsView: View {
GeneralSettingsView()
.environmentObject(SettingsViewModel.shared)
.environmentObject(CurrencyViewModel())
+ .environmentObject(PubkyProfileManager())
.environmentObject(AppViewModel())
}
.preferredColorScheme(.dark)
diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift
index 8bc37e36b..9107b5bc3 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift
@@ -7,6 +7,7 @@ struct ActivityItemView: View {
@EnvironmentObject var activityList: ActivityListViewModel
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var currency: CurrencyViewModel
+ @EnvironmentObject var contactsManager: ContactsManager
@EnvironmentObject var feeEstimatesManager: FeeEstimatesManager
@EnvironmentObject var navigation: NavigationViewModel
@EnvironmentObject var sheets: SheetViewModel
@@ -99,6 +100,10 @@ struct ActivityItemView: View {
return activity.channelId
}
+ private var assignedContact: PubkyContact? {
+ viewModel.activity.contact(in: contactsManager.contacts)
+ }
+
private var navigationTitle: String {
if isTransfer {
return isTransferToSpending
@@ -206,7 +211,7 @@ struct ActivityItemView: View {
statusSection
timestampSection
feeSection
- tagsSection
+ contactTagsSection
note
buttons
@@ -410,7 +415,52 @@ struct ActivityItemView: View {
}
@ViewBuilder
- private var tagsSection: some View {
+ private var contactTagsSection: some View {
+ if assignedContact != nil, !viewModel.tags.isEmpty {
+ HStack(alignment: .top, spacing: 16) {
+ contactCell
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ tagsCell
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ } else if assignedContact != nil {
+ contactCell
+ } else if !viewModel.tags.isEmpty {
+ tagsCell
+ }
+ }
+
+ @ViewBuilder
+ private var contactCell: some View {
+ if let assignedContact {
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(t("wallet__activity_contact"))
+ .padding(.bottom, 8)
+
+ Button {
+ navigation.navigate(.contactDetail(publicKey: assignedContact.publicKey))
+ } label: {
+ HStack(spacing: 8) {
+ PubkyContactAvatar(contact: assignedContact, size: 24)
+ BodySSBText(assignedContact.displayName)
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .background(Color.gray6)
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
+ .padding(.bottom, 16)
+
+ Divider()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var tagsCell: some View {
if !viewModel.tags.isEmpty {
VStack(alignment: .leading, spacing: 0) {
CaptionMText(t("wallet__tags"))
@@ -459,15 +509,20 @@ struct ActivityItemView: View {
VStack(spacing: 16) {
HStack(spacing: 16) {
CustomButton(
- title: t("wallet__activity_assign"), size: .small,
- icon: Image("user-plus")
+ title: assignedContact == nil ? t("wallet__activity_assign") : t("wallet__activity_detach"), size: .small,
+ icon: Image(assignedContact == nil ? "user-plus" : "user-minus")
.foregroundColor(accentColor),
- isDisabled: true,
shouldExpand: true
) {
- // TODO: add assign contact action
- Logger.info("Assign contact action not implemented")
+ if assignedContact == nil {
+ navigation.navigate(.assignActivityContact(activityId: viewModel.activityId))
+ } else {
+ Task {
+ await detachContact()
+ }
+ }
}
+ .accessibilityIdentifier(assignedContact == nil ? "ActivityAssignContact" : "ActivityDetachContact")
CustomButton(
title: t("wallet__activity_tag"), size: .small,
@@ -528,6 +583,16 @@ struct ActivityItemView: View {
}
.frame(maxWidth: .infinity)
}
+
+ private func detachContact() async {
+ do {
+ try await activityList.setContact(nil, forPaymentId: viewModel.activityId)
+ await viewModel.refreshActivity()
+ } catch {
+ Logger.error("Failed to detach contact from activity \(viewModel.activityId): \(error)", context: "ActivityItemView")
+ app.toast(type: .error, title: t("contacts__error_saving"), description: error.localizedDescription)
+ }
+ }
}
struct ZigzagDivider: View {
diff --git a/Bitkit/Views/Wallets/Activity/ActivityRow.swift b/Bitkit/Views/Wallets/Activity/ActivityRow.swift
index 5cd91bcc4..6e8f83952 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityRow.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityRow.swift
@@ -6,12 +6,14 @@ struct ActivityRow: View {
let feeEstimates: FeeRates?
let contact: PubkyContact?
let titleOverride: String?
+ let showContactAvatar: Bool
- init(item: Activity, feeEstimates: FeeRates?, contact: PubkyContact? = nil, titleOverride: String? = nil) {
+ init(item: Activity, feeEstimates: FeeRates?, contact: PubkyContact? = nil, titleOverride: String? = nil, showContactAvatar: Bool = true) {
self.item = item
self.feeEstimates = feeEstimates
self.contact = contact
self.titleOverride = titleOverride
+ self.showContactAvatar = showContactAvatar
}
private var rowTitleOverride: String? {
@@ -51,13 +53,21 @@ struct ActivityRow: View {
}
}
+ private var rowContactAvatar: PubkyContact? {
+ guard showContactAvatar, contactTitle != nil else {
+ return nil
+ }
+
+ return contact
+ }
+
var body: some View {
Group {
switch item {
case let .lightning(activity):
- ActivityRowLightning(item: activity, titleOverride: rowTitleOverride)
+ ActivityRowLightning(item: activity, contact: rowContactAvatar, titleOverride: rowTitleOverride)
case let .onchain(activity):
- ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, titleOverride: rowTitleOverride)
+ ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, contact: rowContactAvatar, titleOverride: rowTitleOverride)
}
}
.padding(16)
diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift
index 6b338291a..de2ee65b0 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift
@@ -3,10 +3,12 @@ import SwiftUI
struct ActivityRowLightning: View {
let item: LightningActivity
+ let contact: PubkyContact?
let titleOverride: String?
- init(item: LightningActivity, titleOverride: String? = nil) {
+ init(item: LightningActivity, contact: PubkyContact? = nil, titleOverride: String? = nil) {
self.item = item
+ self.contact = contact
self.titleOverride = titleOverride
}
@@ -47,7 +49,11 @@ struct ActivityRowLightning: View {
var body: some View {
HStack(spacing: 16) {
- ActivityIcon(activity: .lightning(item), size: 40, context: .row)
+ if let contact {
+ PubkyContactAvatar(contact: contact, size: 40)
+ } else {
+ ActivityIcon(activity: .lightning(item), size: 40, context: .row)
+ }
VStack(alignment: .leading, spacing: 2) {
BodyMSBText(status).lineLimit(1)
diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift
index 0149a4448..3695b2fca 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift
@@ -4,11 +4,13 @@ import SwiftUI
struct ActivityRowOnchain: View {
let item: OnchainActivity
let feeEstimates: FeeRates?
+ let contact: PubkyContact?
let titleOverride: String?
- init(item: OnchainActivity, feeEstimates: FeeRates?, titleOverride: String? = nil) {
+ init(item: OnchainActivity, feeEstimates: FeeRates?, contact: PubkyContact? = nil, titleOverride: String? = nil) {
self.item = item
self.feeEstimates = feeEstimates
+ self.contact = contact
self.titleOverride = titleOverride
}
@@ -78,7 +80,11 @@ struct ActivityRowOnchain: View {
var body: some View {
HStack(spacing: 16) {
- ActivityIcon(activity: .onchain(item), size: 40, isCpfpChild: isCpfpChild, context: .row)
+ if let contact {
+ PubkyContactAvatar(contact: contact, size: 40)
+ } else {
+ ActivityIcon(activity: .onchain(item), size: 40, isCpfpChild: isCpfpChild, context: .row)
+ }
VStack(alignment: .leading, spacing: 2) {
BodyMSBText(status).lineLimit(1)
diff --git a/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift b/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift
new file mode 100644
index 000000000..ee32f2d58
--- /dev/null
+++ b/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift
@@ -0,0 +1,69 @@
+import SwiftUI
+
+struct AssignActivityContactView: View {
+ @EnvironmentObject private var activityList: ActivityListViewModel
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var contactsManager: ContactsManager
+ @EnvironmentObject private var navigation: NavigationViewModel
+
+ let activityId: String
+ @State private var selectedContactKey: String?
+
+ private var contacts: [PubkyContact] {
+ contactsManager.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ NavigationBar(title: t("slashtags__contact_assign"))
+ .padding(.horizontal, 16)
+
+ if contacts.isEmpty {
+ Spacer()
+ BodyMText(t("slashtags__contacts_no_found"), textColor: .white64)
+ Spacer()
+ } else {
+ ScrollView(showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64)
+ .padding(.bottom, 16)
+
+ CustomDivider()
+
+ ForEach(contacts) { contact in
+ PubkyContactRow(
+ contact: contact,
+ verticalPadding: 24,
+ isLoading: selectedContactKey == contact.publicKey
+ ) {
+ Task {
+ await assign(contact)
+ }
+ }
+ .accessibilityIdentifier("AssignContact-\(contact.publicKey)")
+ }
+ }
+ .padding(.horizontal, 16)
+ .bottomSafeAreaPadding()
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.customBlack)
+ .navigationBarHidden(true)
+ }
+
+ private func assign(_ contact: PubkyContact) async {
+ guard selectedContactKey == nil else { return }
+ selectedContactKey = contact.publicKey
+ defer { selectedContactKey = nil }
+
+ do {
+ try await activityList.setContact(contact.publicKey, forPaymentId: activityId)
+ navigation.navigateBack()
+ } catch {
+ Logger.error("Failed to assign contact to activity \(activityId): \(error)", context: "AssignActivityContactView")
+ app.toast(type: .error, title: t("contacts__error_saving"), description: error.localizedDescription)
+ }
+ }
+}
diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift
index a0ac94b3d..dc1b46832 100644
--- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift
+++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift
@@ -24,7 +24,11 @@ struct LnurlPayAmount: View {
var body: some View {
VStack(spacing: 0) {
- SheetHeader(title: t("wallet__lnurl_p_title"), showBackButton: true)
+ SheetHeader(
+ title: t("wallet__lnurl_p_title"),
+ showBackButton: true,
+ action: AnyView(SendContactHeaderAvatar())
+ )
VStack(alignment: .leading, spacing: 0) {
NumberPadTextField(viewModel: amountViewModel, testIdentifier: "SendNumberField")
diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
index d6672b271..2161ed0da 100644
--- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
+++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
@@ -25,7 +25,11 @@ struct LnurlPayConfirm: View {
var body: some View {
VStack {
- SheetHeader(title: t("wallet__lnurl_p_title"), showBackButton: true)
+ SheetHeader(
+ title: t("wallet__lnurl_p_title"),
+ showBackButton: true,
+ action: AnyView(SendContactHeaderAvatar())
+ )
VStack(alignment: .leading) {
MoneyStack(
diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift
index f189e8b25..d81fe176e 100644
--- a/Bitkit/Views/Wallets/Send/SendAmountView.swift
+++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift
@@ -54,7 +54,11 @@ struct SendAmountView: View {
var body: some View {
VStack(spacing: 0) {
- SheetHeader(title: t("wallet__send_amount"), showBackButton: true)
+ SheetHeader(
+ title: t("wallet__send_amount"),
+ showBackButton: true,
+ action: AnyView(SendContactHeaderAvatar())
+ )
VStack(alignment: .leading, spacing: 0) {
NumberPadTextField(viewModel: amountViewModel, testIdentifier: "SendNumberField")
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index 87368d56d..55547fc7b 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -4,6 +4,7 @@ import SwiftUI
struct SendConfirmationView: View {
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var activityList: ActivityListViewModel
+ @EnvironmentObject var contactsManager: ContactsManager
@EnvironmentObject var currency: CurrencyViewModel
@EnvironmentObject var feeEstimatesManager: FeeEstimatesManager
@EnvironmentObject var settings: SettingsViewModel
@@ -86,9 +87,21 @@ struct SendConfirmationView: View {
return invoice.amountSatoshis == 0
}
+ private var contactPaymentContact: PubkyContact? {
+ guard let publicKey = app.contactPaymentContext?.publicKey else {
+ return nil
+ }
+
+ return contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) })
+ }
+
var body: some View {
VStack(alignment: .leading, spacing: 0) {
- SheetHeader(title: t("wallet__send_review"), showBackButton: !navigationPath.isEmpty)
+ SheetHeader(
+ title: t("wallet__send_review"),
+ showBackButton: !navigationPath.isEmpty,
+ action: AnyView(SendContactHeaderAvatar())
+ )
VStack(alignment: .leading, spacing: 0) {
if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice {
@@ -253,19 +266,25 @@ struct SendConfirmationView: View {
.accessibilityIdentifier("SendConfirmAssetButton")
}
- Button {
- navigateToManual(with: invoice.address)
- } label: {
+ if let contact = contactPaymentContact {
SendSectionView(t("wallet__send_to")) {
- BodySSBText(invoice.address.ellipsis(maxLength: 18))
- .lineLimit(1)
- .truncationMode(.middle)
- .frame(height: 28)
+ contactRecipient(contact)
+ }
+ } else {
+ Button {
+ navigateToManual(with: invoice.address)
+ } label: {
+ SendSectionView(t("wallet__send_to")) {
+ BodySSBText(invoice.address.ellipsis(maxLength: 18))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .frame(height: 28)
+ }
}
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("ReviewUri")
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .buttonStyle(.plain)
- .accessibilityIdentifier("ReviewUri")
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -354,18 +373,24 @@ struct SendConfirmationView: View {
Spacer(minLength: 16)
- Button {
- navigateToManual(with: invoice.bolt11)
- } label: {
+ if let contact = contactPaymentContact {
SendSectionView(t("wallet__send_to")) {
- BodySSBText(invoice.bolt11.ellipsis(maxLength: 18))
- .lineLimit(1)
- .truncationMode(.middle)
- .frame(height: 28)
+ contactRecipient(contact)
}
+ } else {
+ Button {
+ navigateToManual(with: invoice.bolt11)
+ } label: {
+ SendSectionView(t("wallet__send_to")) {
+ BodySSBText(invoice.bolt11.ellipsis(maxLength: 18))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .frame(height: 28)
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("ReviewUri")
}
- .buttonStyle(.plain)
- .accessibilityIdentifier("ReviewUri")
}
HStack(alignment: .top, spacing: 16) {
@@ -445,6 +470,17 @@ struct SendConfirmationView: View {
}
}
+ private func contactRecipient(_ contact: PubkyContact) -> some View {
+ HStack(spacing: 8) {
+ PubkyContactAvatar(contact: contact, size: 24)
+
+ BodySSBText(contact.displayName)
+ .lineLimit(1)
+ }
+ .padding(.vertical, 2)
+ .accessibilityIdentifier("ReviewContactRecipient")
+ }
+
private func performPayment() async throws {
var createdMetadataPaymentId: String? = nil
let contactPublicKey = app.contactPaymentContext?.publicKey
@@ -494,9 +530,7 @@ struct SendConfirmationView: View {
// onTimeout callback already navigated to .pending; suppress throw
return
} catch {
- if let contactPublicKey,
- PrivatePaykitService.isDuplicatePaymentError(error)
- {
+ if let contactPublicKey {
await PrivatePaykitService.shared.discardRemoteLightningEndpoints(
publicKey: contactPublicKey,
paymentHashes: [paymentHash]
diff --git a/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift b/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift
new file mode 100644
index 000000000..301d2d6d0
--- /dev/null
+++ b/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+
+struct SendContactHeaderAvatar: View {
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var contactsManager: ContactsManager
+
+ var body: some View {
+ if let contact {
+ PubkyContactAvatar(contact: contact, size: 32)
+ } else {
+ Spacer()
+ .frame(width: 24, height: 24)
+ }
+ }
+
+ private var contact: PubkyContact? {
+ guard let publicKey = app.contactPaymentContext?.publicKey else {
+ return nil
+ }
+
+ return contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) })
+ }
+}
diff --git a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift
new file mode 100644
index 000000000..3ea377eba
--- /dev/null
+++ b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift
@@ -0,0 +1,103 @@
+import SwiftUI
+
+struct SendContactSelectView: View {
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var contactsManager: ContactsManager
+ @EnvironmentObject private var currency: CurrencyViewModel
+ @EnvironmentObject private var settings: SettingsViewModel
+ @EnvironmentObject private var sheets: SheetViewModel
+ @EnvironmentObject private var wallet: WalletViewModel
+
+ @Binding var navigationPath: [SendRoute]
+ @State private var selectedContactKey: String?
+
+ private var contacts: [PubkyContact] {
+ contactsManager.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ SheetHeader(title: t("wallet__recipient_contact"), showBackButton: true)
+
+ if contacts.isEmpty {
+ Spacer()
+ BodyMText(t("slashtags__contacts_no_found"), textColor: .white64)
+ Spacer()
+ } else {
+ ScrollView(showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64)
+ .padding(.bottom, 16)
+
+ CustomDivider()
+
+ ForEach(contacts) { contact in
+ PubkyContactRow(
+ contact: contact,
+ verticalPadding: 24,
+ isLoading: selectedContactKey == contact.publicKey
+ ) {
+ Task {
+ await pay(contact)
+ }
+ }
+ .accessibilityIdentifier("SendContact-\(contact.publicKey)")
+ }
+ }
+ }
+ }
+ }
+ .navigationBarHidden(true)
+ .padding(.horizontal, 16)
+ .sheetBackground()
+ }
+
+ private func pay(_ contact: PubkyContact) async {
+ guard selectedContactKey == nil else { return }
+ selectedContactKey = contact.publicKey
+ defer { selectedContactKey = nil }
+
+ do {
+ let result = try await PrivatePaykitService.shared.beginSavedContactPayment(to: contact.publicKey, wallet: wallet)
+
+ switch result {
+ case let .opened(paymentRequest):
+ _ = await openContactPayment(paymentRequest: paymentRequest, publicKey: contact.publicKey)
+ case .noEndpoint, .notOpened:
+ if let messageKey = result.contactPaymentFailureMessageKey {
+ app.toast(
+ type: .warning,
+ title: t("slashtags__error_pay_title"),
+ description: t(messageKey)
+ )
+ }
+ }
+ } catch {
+ Logger.error("Failed to pay contact \(PubkyPublicKeyFormat.redacted(contact.publicKey)): \(error)", context: "SendContactSelectView")
+ app.toast(type: .error, title: t("slashtags__error_pay_title"), description: error.localizedDescription)
+ }
+ }
+
+ @MainActor
+ private func openContactPayment(paymentRequest: String, publicKey: String) async -> Bool {
+ do {
+ try await app.handleScannedData(paymentRequest)
+ } catch {
+ Logger.warn("Failed to decode contact payment request: \(error)", context: "SendContactSelectView")
+ app.toast(
+ type: .warning,
+ title: t("slashtags__error_pay_title"),
+ description: t("slashtags__error_pay_not_opened_msg")
+ )
+ return false
+ }
+
+ guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else {
+ return false
+ }
+
+ app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey)
+ navigationPath.append(route)
+ return true
+ }
+}
diff --git a/Bitkit/Views/Wallets/Send/SendOptionsView.swift b/Bitkit/Views/Wallets/Send/SendOptionsView.swift
index 64904d404..e087f9dbd 100644
--- a/Bitkit/Views/Wallets/Send/SendOptionsView.swift
+++ b/Bitkit/Views/Wallets/Send/SendOptionsView.swift
@@ -42,7 +42,6 @@ struct SendOptionsView: View {
icon: "users",
iconColor: .brandAccent,
title: t("wallet__recipient_contact"),
- isDisabled: true,
testID: "RecipientContact"
) {
handleContact()
@@ -105,8 +104,7 @@ struct SendOptionsView: View {
}
func handleContact() {
- // TODO: implement contacts
- // navigationPath.append(.contact)
+ navigationPath.append(.contact)
}
}
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index 844d99110..6fc4750ee 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -2,6 +2,7 @@ import SwiftUI
enum SendRoute: Hashable {
case options
+ case contact
case manual
case amount
case utxoSelection
@@ -291,6 +292,8 @@ struct SendSheet: View {
switch route {
case .options:
SendOptionsView(navigationPath: $navigationPath)
+ case .contact:
+ SendContactSelectView(navigationPath: $navigationPath)
case .manual:
SendEnterManuallyView(navigationPath: $navigationPath)
case .amount:
diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift
index 5bf8f12a1..f19d81f27 100644
--- a/BitkitTests/PrivatePaykitServiceTests.swift
+++ b/BitkitTests/PrivatePaykitServiceTests.swift
@@ -62,13 +62,15 @@ final class PrivatePaykitServiceTests: XCTestCase {
func testStaleLinkFailureClassificationUsesTypedPaykitErrors() async {
let service = PrivatePaykitService()
- let noiseFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "noise state decrypt failed"))
+ let noiseFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "bad mac while decrypting payload"))
let linkHandleFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Validation(reason: "Unknown encrypted-link handle: 123"))
+ let counterFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "counter mismatch"))
let networkFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "connection timed out"))
let sessionFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Session(reason: "No active session"))
XCTAssertTrue(noiseFailure)
XCTAssertTrue(linkHandleFailure)
+ XCTAssertFalse(counterFailure)
XCTAssertFalse(networkFailure)
XCTAssertFalse(sessionFailure)
}
@@ -98,6 +100,16 @@ final class PrivatePaykitServiceTests: XCTestCase {
)
}
+ func testReceivedPrivateInvoiceHashKeepsContactAttribution() async {
+ let service = PrivatePaykitService()
+ let publicKey = "pubkycontact"
+
+ await service.rememberReceivedInvoicePaymentHash("payment-hash", publicKey: publicKey)
+
+ let matchedPublicKey = await service.contactPublicKey(forPrivateInvoicePaymentHash: "payment-hash")
+ XCTAssertEqual(matchedPublicKey, publicKey)
+ }
+
func testWalletBackupDecodesExistingPayloadWithoutPrivatePaykitFields() throws {
let data = #"{"version":1,"createdAt":123,"transfers":[]}"#.data(using: .utf8)!
let payload = try JSONDecoder().decode(WalletBackupV1.self, from: data)
@@ -205,4 +217,110 @@ final class PrivatePaykitServiceTests: XCTestCase {
XCTAssertFalse(cacheJson.contains("secret-link"))
XCTAssertFalse(cacheJson.contains("secret-handshake"))
}
+
+ func testCloseAndClearCanMarkProfileRecoveryPendingWhenPrivateContactStateExists() async {
+ let service = PrivatePaykitService()
+ let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo"
+ await service.restoreBackup([
+ publicKey: PrivatePaykitContactLinkBackupV1(
+ publicKey: publicKey,
+ linkSnapshotHex: nil,
+ handshakeSnapshotHex: nil,
+ remoteEndpoints: [:],
+ linkCompletedAt: 123,
+ handshakeUpdatedAt: nil,
+ recoveryStartedAt: nil,
+ mainRecoveryAttemptId: nil,
+ responderRecoveryAttemptId: nil
+ ),
+ ])
+
+ PrivatePaykitService.setProfileRecoveryPending(false)
+ await service.closeAndClear(markProfileRecoveryPending: true)
+ defer { PrivatePaykitService.setProfileRecoveryPending(false) }
+
+ XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending)
+ }
+
+ func testMarkProfileRecoveryPendingUsesPrivateContactStateBeforeContactDelete() async {
+ let service = PrivatePaykitService()
+ let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo"
+ await service.restoreBackup([
+ publicKey: PrivatePaykitContactLinkBackupV1(
+ publicKey: publicKey,
+ linkSnapshotHex: nil,
+ handshakeSnapshotHex: nil,
+ remoteEndpoints: [:],
+ linkCompletedAt: 123,
+ handshakeUpdatedAt: nil,
+ recoveryStartedAt: nil,
+ mainRecoveryAttemptId: nil,
+ responderRecoveryAttemptId: nil
+ ),
+ ])
+
+ PrivatePaykitService.setProfileRecoveryPending(false)
+ await service.markProfileRecoveryPendingIfNeeded()
+ await service.pruneUnsavedContactState(savedPublicKeys: [])
+ defer { PrivatePaykitService.setProfileRecoveryPending(false) }
+
+ XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending)
+ let snapshot = await service.backupSnapshot()
+ XCTAssertNil(snapshot)
+ }
+
+ func testProfileRecoveryPurgeFailureKeepsMarkerPending() async {
+ let service = PrivatePaykitService()
+
+ PrivatePaykitService.setProfileRecoveryPending(false)
+ let error = await service.handleProfileRecoveryPurgeFailure(requireImmediatePublication: false)
+ defer { PrivatePaykitService.setProfileRecoveryPending(false) }
+
+ XCTAssertNil(error)
+ XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending)
+ }
+
+ func testProfileRecoveryPurgeFailureFailsImmediateMode() async {
+ let service = PrivatePaykitService()
+
+ PrivatePaykitService.setProfileRecoveryPending(false)
+ let error = await service.handleProfileRecoveryPurgeFailure(requireImmediatePublication: true)
+ defer { PrivatePaykitService.setProfileRecoveryPending(false) }
+
+ guard case .privateUnavailable = error as? PrivatePaykitError else {
+ return XCTFail("Expected privateUnavailable")
+ }
+ XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending)
+ }
+
+ func testProfileRecoveryStateClearsOldEndpointMetadata() async {
+ let service = PrivatePaykitService()
+ let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo"
+ let remoteEndpoints = [
+ PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue: #"{"value":"bcrt1qcached"}"#,
+ ]
+ await service.restoreBackup([
+ publicKey: PrivatePaykitContactLinkBackupV1(
+ publicKey: publicKey,
+ linkSnapshotHex: nil,
+ handshakeSnapshotHex: nil,
+ remoteEndpoints: remoteEndpoints,
+ linkCompletedAt: 123,
+ handshakeUpdatedAt: 100,
+ recoveryStartedAt: nil,
+ mainRecoveryAttemptId: nil,
+ responderRecoveryAttemptId: nil
+ ),
+ ])
+
+ await service.markContactForProfileRecovery(publicKey, startedAt: 456)
+ let snapshot = await service.backupSnapshot()?[publicKey]
+
+ XCTAssertEqual(snapshot?.recoveryStartedAt, 456)
+ XCTAssertNil(snapshot?.linkSnapshotHex)
+ XCTAssertNil(snapshot?.handshakeSnapshotHex)
+ XCTAssertEqual(snapshot?.remoteEndpoints, [:])
+ XCTAssertNil(snapshot?.linkCompletedAt)
+ XCTAssertNil(snapshot?.handshakeUpdatedAt)
+ }
}
diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift
index f4593ae2f..06005e685 100644
--- a/BitkitTests/PublicPaykitServiceTests.swift
+++ b/BitkitTests/PublicPaykitServiceTests.swift
@@ -215,6 +215,20 @@ final class PublicPaykitServiceTests: XCTestCase {
XCTAssertTrue(plan.methodIdsToRemove.isEmpty)
}
+ func testPublishedEndpointSyncPlanRemovesAllManagedEndpointsWhenDesiredSetIsEmpty() {
+ let plan = PublicPaykitService.publishedEndpointSyncPlan(
+ existingEndpoints: [
+ .bitcoinLightningBolt11: #"{"value":"lnbc1old"}"#,
+ .bitcoinLightningLnurl: #"{"value":"lnurl1external"}"#,
+ .bitcoinOnchainP2wpkh: #"{"value":"bc1qold"}"#,
+ ],
+ desiredEndpoints: []
+ )
+
+ XCTAssertTrue(plan.endpointsToSet.isEmpty)
+ XCTAssertEqual(plan.methodIdsToRemove, [.bitcoinLightningBolt11, .bitcoinOnchainP2wpkh])
+ }
+
private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint {
PublicPaykitService.Endpoint(
methodId: methodId,
diff --git a/changelog.d/next/539.added.md b/changelog.d/next/539.added.md
new file mode 100644
index 000000000..936d76101
--- /dev/null
+++ b/changelog.d/next/539.added.md
@@ -0,0 +1 @@
+Added contact-first send, activity contact assignment, contact avatars in activity, and payment preference controls.