diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj
index 09df5e0d..43c2c04f 100644
--- a/Bitkit.xcodeproj/project.pbxproj
+++ b/Bitkit.xcodeproj/project.pbxproj
@@ -87,6 +87,7 @@
Extensions/PaymentDetails.swift,
Models/BlocktankNotificationType.swift,
Models/LnPeer.swift,
+ Models/PubkyPublicKeyFormat.swift,
Models/Toast.swift,
Models/Transfer.swift,
Models/TransferType.swift,
@@ -117,6 +118,7 @@
Extensions/PaymentDetails.swift,
Models/BlocktankNotificationType.swift,
Models/LnPeer.swift,
+ Models/PubkyPublicKeyFormat.swift,
Models/Toast.swift,
Services/CoreService.swift,
Services/GeoService.swift,
@@ -904,8 +906,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
- kind = revision;
- revision = cd1253291b1582759d569372d5942b8871527ea1;
+ kind = exactVersion;
+ version = 0.1.0-rc5;
};
};
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
@@ -936,8 +938,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
- kind = revision;
- revision = 99bd86bb60c1f14e8ce8a6356cd2ab36f222fc69;
+ kind = exactVersion;
+ version = 0.1.58;
};
};
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 06a83812..7283543a 100644
--- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -6,7 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/synonymdev/bitkit-core",
"state" : {
- "revision" : "99bd86bb60c1f14e8ce8a6356cd2ab36f222fc69"
+ "revision" : "47bd506bb46ae885191a265f76245ab357a93f28",
+ "version" : "0.1.58"
}
},
{
@@ -40,7 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pubky/paykit-rs",
"state" : {
- "revision" : "cd1253291b1582759d569372d5942b8871527ea1"
+ "revision" : "04759b7b9bca7a0dd8a29e724f11e984db361241",
+ "version" : "0.1.0-rc5"
}
},
{
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index a6cc9f34..efe05536 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -560,6 +560,7 @@ struct AppScene: View {
Task {
await clearDeliveredNotifications()
await LightningService.shared.reconnectPeers()
+ await wallet.refreshPublicPaykitEndpointsOnForeground()
}
}
}
diff --git a/Bitkit/Components/Activity/ActivityList.swift b/Bitkit/Components/Activity/ActivityList.swift
index a3a570fb..33814d07 100644
--- a/Bitkit/Components/Activity/ActivityList.swift
+++ b/Bitkit/Components/Activity/ActivityList.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct ActivityList: View {
@EnvironmentObject var activity: ActivityListViewModel
+ @EnvironmentObject var contactsManager: ContactsManager
@EnvironmentObject var feeEstimatesManager: FeeEstimatesManager
@State private var isHorizontalSwipe = false
@@ -28,7 +29,11 @@ struct ActivityList: View {
case let .activity(item):
NavigationLink(value: Route.activityDetail(item)) {
- ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates)
+ ActivityRow(
+ item: item,
+ feeEstimates: feeEstimatesManager.estimates,
+ contact: item.contact(in: contactsManager.contacts)
+ )
}
.accessibilityIdentifier("Activity-\(index)")
.disabled(isHorizontalSwipe)
diff --git a/Bitkit/Components/ContactAvatarLetter.swift b/Bitkit/Components/ContactAvatarLetter.swift
new file mode 100644
index 00000000..d29430a9
--- /dev/null
+++ b/Bitkit/Components/ContactAvatarLetter.swift
@@ -0,0 +1,42 @@
+import SwiftUI
+
+struct ContactAvatarLetter: View {
+ let source: String
+ let size: CGFloat
+ var backgroundColor: Color = .white.opacity(0.1)
+ var strokeColor: Color?
+ var strokeWidth: CGFloat = 0
+
+ private var letter: String {
+ String(source.prefix(1)).uppercased()
+ }
+
+ var body: some View {
+ Circle()
+ .fill(backgroundColor)
+ .frame(width: size, height: size)
+ .overlay {
+ avatarText
+ }
+ .overlay {
+ if let strokeColor, strokeWidth > 0 {
+ Circle()
+ .stroke(strokeColor, lineWidth: strokeWidth)
+ }
+ }
+ .accessibilityHidden(true)
+ }
+
+ @ViewBuilder
+ private var avatarText: some View {
+ if size >= 72 {
+ HeadlineText(letter)
+ } else if size >= 56 {
+ TitleText(letter)
+ } else if size >= 44 {
+ BodyMSBText(letter)
+ } else {
+ CaptionBText(letter, textColor: .textPrimary)
+ }
+ }
+}
diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift
index 4da84ebc..845b69e0 100644
--- a/Bitkit/Constants/Env.swift
+++ b/Bitkit/Constants/Env.swift
@@ -272,7 +272,7 @@ enum Env {
case .bitcoin:
return "/pub/bitkit.to/:rw,/pub/pubky.app/:r,/pub/paykit/v0/:rw"
default:
- return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/staging.paykit/v0/:rw"
+ return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/paykit/v0/:rw"
}
}
diff --git a/Bitkit/Extensions/Activity+Contact.swift b/Bitkit/Extensions/Activity+Contact.swift
new file mode 100644
index 00000000..a2b9cb80
--- /dev/null
+++ b/Bitkit/Extensions/Activity+Contact.swift
@@ -0,0 +1,18 @@
+import BitkitCore
+
+extension Activity {
+ func contact(in contacts: [PubkyContact]) -> PubkyContact? {
+ guard let contactPublicKey else { return nil }
+ return contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, contactPublicKey) })
+ }
+
+ private var contactPublicKey: String? {
+ switch self {
+ case let .lightning(lightning):
+ return lightning.contact
+
+ case let .onchain(onchain):
+ return onchain.contact
+ }
+ }
+}
diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift
index 1eae66d3..a787d4e9 100644
--- a/Bitkit/MainNavView.swift
+++ b/Bitkit/MainNavView.swift
@@ -385,6 +385,7 @@ struct MainNavView: View {
}
case .contactsIntro: ContactsIntroView()
case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey)
+ case let .contactActivity(publicKey): ContactActivityView(publicKey: publicKey)
case .contactImportOverview:
if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) {
missingPendingImportView(fallbackRoute: fallbackRoute)
diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift
index 067f6420..a8c47860 100644
--- a/Bitkit/Managers/ContactsManager.swift
+++ b/Bitkit/Managers/ContactsManager.swift
@@ -11,44 +11,9 @@ private func stripPubkyPrefix(_ key: String) -> String {
key.hasPrefix(pubkyPrefix) ? String(key.dropFirst(pubkyPrefix.count)) : key
}
-enum PubkyPublicKeyFormat {
- private static let rawKeyLength = 52
- private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769")
-
- static let maximumInputLength = pubkyPrefix.count + rawKeyLength
-
- static func bounded(_ input: String) -> String {
- String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(maximumInputLength))
- }
-
- static func normalized(_ input: String) -> String? {
- let boundedInput = bounded(input)
- let rawKey = stripPubkyPrefix(boundedInput)
-
- guard rawKey.count == rawKeyLength else {
- return nil
- }
-
- guard rawKey.allSatisfy({ allowedCharacters.contains($0) }) else {
- return nil
- }
-
- return ensurePubkyPrefix(rawKey)
- }
-
- static func matches(_ lhs: String?, _ rhs: String?) -> Bool {
- guard let lhs = lhs.flatMap(normalized),
- let rhs = rhs.flatMap(normalized)
- else {
- return false
- }
-
- return lhs == rhs
- }
-}
-
enum AddContactValidationResult: Equatable {
case empty
+ case existingContact
case invalidKey
case ownKey
case valid(normalizedKey: String)
@@ -57,6 +22,8 @@ enum AddContactValidationResult: Equatable {
switch self {
case .empty, .valid:
nil
+ case .existingContact:
+ t("contacts__add_error_existing")
case .invalidKey:
t("contacts__add_error_invalid_key")
case .ownKey:
@@ -65,7 +32,11 @@ enum AddContactValidationResult: Equatable {
}
}
-func resolveAddContactValidation(input: String, ownPublicKey: String?) -> AddContactValidationResult {
+func resolveAddContactValidation(
+ input: String,
+ ownPublicKey: String?,
+ existingContacts: [PubkyContact] = []
+) -> AddContactValidationResult {
let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedInput.isEmpty else {
@@ -80,12 +51,17 @@ func resolveAddContactValidation(input: String, ownPublicKey: String?) -> AddCon
return .invalidKey
}
+ if existingContacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, normalizedKey) }) {
+ return .existingContact
+ }
+
return .valid(normalizedKey: normalizedKey)
}
enum ContactsManagerError: LocalizedError {
case invalidPublicKey
case cannotAddYourself
+ case alreadyExists
var errorDescription: String? {
switch self {
@@ -93,6 +69,8 @@ enum ContactsManagerError: LocalizedError {
return t("contacts__add_error_invalid_key")
case .cannotAddYourself:
return t("contacts__add_error_self")
+ case .alreadyExists:
+ return t("contacts__add_error_existing")
}
}
}
@@ -142,6 +120,7 @@ class ContactsManager: ObservableObject {
@Published var isLoading = false
@Published var hasLoaded = false
@Published var loadErrorMessage: String?
+ @Published var shouldOpenAddContactSheet = false
/// Temporarily holds contacts discovered during import (e.g., from pubky.app after Ring auth).
/// Cleared after import is completed or discarded.
@@ -164,6 +143,7 @@ class ContactsManager: ObservableObject {
isLoading = false
hasLoaded = false
loadErrorMessage = nil
+ shouldOpenAddContactSheet = false
clearPendingImport()
}
@@ -185,7 +165,7 @@ class ContactsManager: ObservableObject {
defer { isLoading = false }
let basePath = contactsBasePath
- Logger.info("Loading contacts from \(basePath) for \(publicKey)", context: "ContactsManager")
+ Logger.info("Loading contacts from \(basePath) for \(PubkyPublicKeyFormat.redacted(publicKey))", context: "ContactsManager")
do {
let sessionSecret = try getSessionSecret()
@@ -214,7 +194,10 @@ class ContactsManager: ObservableObject {
let profile = profileData.toProfile(publicKey: prefixedKey)
return .success(PubkyContact(publicKey: prefixedKey, profile: profile))
} catch {
- Logger.warn("Failed to load contact data for '\(prefixedKey)': \(error)", context: "ContactsManager")
+ Logger.warn(
+ "Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)",
+ context: "ContactsManager"
+ )
return .failure(error)
}
}
@@ -290,9 +273,8 @@ class ContactsManager: ObservableObject {
throw ContactsManagerError.cannotAddYourself
}
- guard !contacts.contains(where: { $0.publicKey == prefixedKey }) else {
- Logger.debug("Contact \(prefixedKey) already exists, skipping add", context: "ContactsManager")
- return
+ guard !contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) }) else {
+ throw ContactsManagerError.alreadyExists
}
// Use existing profile if provided (e.g., already fetched during preview),
@@ -315,7 +297,7 @@ class ContactsManager: ObservableObject {
let contactData = PubkyProfileData.from(profile: profile)
try await savePubkyProfileData(publicKey: prefixedKey, data: contactData)
- Logger.info("Added contact \(prefixedKey)", context: "ContactsManager")
+ Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager")
let contact = PubkyContact(publicKey: prefixedKey, profile: profile)
contacts.append(contact)
@@ -338,7 +320,7 @@ class ContactsManager: ObservableObject {
try await savePubkyProfileData(publicKey: key, data: contactData)
return .success(PubkyContact(publicKey: key, profile: profile))
} catch {
- Logger.warn("Failed to save imported contact '\(key)': \(error)", context: "ContactsManager")
+ Logger.warn("Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(key))': \(error)", context: "ContactsManager")
return .failure(error)
}
}
@@ -400,7 +382,7 @@ class ContactsManager: ObservableObject {
contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
- Logger.info("Updated contact \(prefixedKey)", context: "ContactsManager")
+ Logger.info("Updated contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager")
}
// MARK: - Delete Contact
@@ -418,7 +400,7 @@ class ContactsManager: ObservableObject {
)
}.value
- Logger.info("Removed contact \(prefixedKey)", context: "ContactsManager")
+ Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager")
contacts.removeAll { $0.publicKey == prefixedKey }
}
@@ -459,18 +441,18 @@ class ContactsManager: ObservableObject {
deletedKeys.insert(ensurePubkyPrefix(contactKey))
} catch {
firstError = firstError ?? error
- Logger.warn("Failed to delete contact '\(contactKey)': \(error)", context: "ContactsManager")
+ Logger.warn("Failed to delete contact '\(PubkyPublicKeyFormat.redacted(contactKey))': \(error)", context: "ContactsManager")
}
}
- if !deletedKeys.isEmpty {
- contacts.removeAll { deletedKeys.contains($0.publicKey) }
- }
-
if let firstError {
+ if !deletedKeys.isEmpty {
+ contacts.removeAll { deletedKeys.contains($0.publicKey) }
+ }
throw firstError
}
+ // All remote deletes succeeded, so clear any local-only contacts too.
contacts.removeAll()
Logger.info("Deleted all contacts", context: "ContactsManager")
}
@@ -602,11 +584,14 @@ class ContactsManager: ObservableObject {
return try await PubkyProfileManager.resolveRemoteProfile(publicKey: prefixedKey)
} catch {
if includePlaceholder, Self.isMissingContactsDataError(error) {
- Logger.info("No remote profile found for '\(prefixedKey)', using placeholder", context: "ContactsManager")
+ Logger.info(
+ "No remote profile found for '\(PubkyPublicKeyFormat.redacted(prefixedKey))', using placeholder",
+ context: "ContactsManager"
+ )
return PubkyProfile.placeholder(publicKey: prefixedKey)
}
- Logger.warn("Failed to resolve contact profile for '\(prefixedKey)': \(error)", context: "ContactsManager")
+ Logger.warn("Failed to resolve contact profile for '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", context: "ContactsManager")
throw error
}
}
@@ -620,7 +605,7 @@ class ContactsManager: ObservableObject {
} catch {
if attempt == 0, !(error is CancellationError) {
Logger.warn(
- "Retrying imported contact profile resolution for '\(prefixedKey)' after transient error: \(error)",
+ "Retrying imported contact profile resolution for '\(PubkyPublicKeyFormat.redacted(prefixedKey))' after transient error: \(error)",
context: "ContactsManager"
)
try? await Task.sleep(nanoseconds: 250_000_000)
@@ -628,7 +613,7 @@ class ContactsManager: ObservableObject {
}
Logger.warn(
- "Falling back to placeholder while importing contact '\(prefixedKey)': \(error)",
+ "Falling back to placeholder while importing contact '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)",
context: "ContactsManager"
)
return PubkyProfile.placeholder(publicKey: prefixedKey)
@@ -697,13 +682,11 @@ class ContactsManager: ObservableObject {
}
let normalized = message.lowercased()
- let indicatesMissingResource = normalized.contains("404")
+ return normalized.contains("404")
|| normalized.contains("no such file")
|| normalized.contains("does not exist")
|| normalized.contains("profile not found")
|| normalized.contains("profilenotfound")
|| (normalized.contains("fetch failed") && normalized.contains("not found"))
-
- return indicatesMissingResource
}
}
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index 5e8eca3b..d896cd9b 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -759,11 +759,36 @@ class PubkyProfileManager: ObservableObject {
await PubkyImageCache.shared.clear()
UserDefaults.standard.removeObject(forKey: cachedNameKey)
UserDefaults.standard.removeObject(forKey: cachedImageUriKey)
+ clearPublicPaykitSharingState()
notifyAppStateBackupChanged()
}
+ private static func clearPublicPaykitSharingState() {
+ UserDefaults.standard.set(false, forKey: "sharesPublicPaykitEndpoints")
+ UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints")
+ UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11")
+ UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11PaymentHash")
+ UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11ExpiresAt")
+ }
+
+ static func removePublicPaykitEndpoints(context: String) async throws {
+ do {
+ try await PublicPaykitService.removePublishedEndpoints()
+ } catch PubkyServiceError.sessionNotActive {
+ Logger.debug("Skipping public Paykit endpoint cleanup because no session is active", context: context)
+ } catch {
+ Logger.warn("Failed to remove public Paykit endpoints before clearing session: \(error)", context: context)
+ throw error
+ }
+ }
+
+ static func removePublicPaykitEndpointsBestEffort(context: String) async {
+ try? await removePublicPaykitEndpoints(context: context)
+ }
+
func signOut() async {
await Task.detached {
+ await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut")
do {
try await PubkyService.signOut()
} catch {
diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift
index 67d8f14e..5b057e4c 100644
--- a/Bitkit/Managers/ScannerManager.swift
+++ b/Bitkit/Managers/ScannerManager.swift
@@ -3,6 +3,7 @@ import SwiftUI
import Vision
enum ScannerContext {
+ case addContact
case main
case send
case electrum
@@ -40,6 +41,8 @@ class ScannerManager: ObservableObject {
Haptics.play(.scanSuccess)
switch context {
+ case .addContact:
+ handleAddContactScan(uri)
case .main:
await handleMainScan(uri)
case .send:
@@ -49,6 +52,16 @@ class ScannerManager: ObservableObject {
}
}
+ private func handleAddContactScan(_ input: String) {
+ navigation?.navigateBack()
+
+ guard !handlePubkyRouteIfNeeded(input) else {
+ return
+ }
+
+ navigation?.navigate(.addContact(publicKey: input))
+ }
+
private func handleMainScan(_ uri: String) async {
guard let app else { return }
diff --git a/Bitkit/Models/ActivityDisplayConstants.swift b/Bitkit/Models/ActivityDisplayConstants.swift
new file mode 100644
index 00000000..d9b610ea
--- /dev/null
+++ b/Bitkit/Models/ActivityDisplayConstants.swift
@@ -0,0 +1,3 @@
+enum ActivityDisplayConstants {
+ static let maxHomeActivityItems = 4
+}
diff --git a/Bitkit/Models/PubkyPublicKeyFormat.swift b/Bitkit/Models/PubkyPublicKeyFormat.swift
new file mode 100644
index 00000000..64e0c86e
--- /dev/null
+++ b/Bitkit/Models/PubkyPublicKeyFormat.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+enum PubkyPublicKeyFormat {
+ private static let prefix = "pubky"
+ private static let rawKeyLength = 52
+ private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769")
+
+ static let maximumInputLength = prefix.count + rawKeyLength
+
+ static func bounded(_ input: String) -> String {
+ String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(maximumInputLength))
+ }
+
+ static func normalized(_ input: String) -> String? {
+ let boundedInput = bounded(input)
+ let rawKey = boundedInput.hasPrefix(prefix) ? String(boundedInput.dropFirst(prefix.count)) : boundedInput
+
+ guard rawKey.count == rawKeyLength else {
+ return nil
+ }
+
+ guard rawKey.allSatisfy({ allowedCharacters.contains($0) }) else {
+ return nil
+ }
+
+ return "\(prefix)\(rawKey)"
+ }
+
+ static func matches(_ lhs: String?, _ rhs: String?) -> Bool {
+ guard let lhs = lhs.flatMap(normalized),
+ let rhs = rhs.flatMap(normalized)
+ else {
+ return false
+ }
+
+ return lhs == rhs
+ }
+
+ static func redacted(_ input: String) -> String {
+ let value = normalized(input) ?? input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard value.count > 12 else {
+ return value
+ }
+
+ return "\(value.prefix(12))..."
+ }
+}
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 766d3dfd..82914c7b 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -958,6 +958,8 @@
"slashtags__onboarding_text" = "Get automatic updates from your Bitkit contacts, pay them, and follow their public profiles.";
"slashtags__onboarding_button" = "Add First Contact";
"contacts__detail_title" = "Contact";
+"contacts__activity_sent_to" = "Sent to {name}";
+"contacts__activity_received_from" = "Received from {name}";
"contacts__detail_empty_state" = "Unable to load contact.";
"contacts__empty_state" = "You don't have any contacts yet.";
"contacts__intro_add_contact" = "Add Contact";
@@ -973,6 +975,7 @@
"contacts__add_pubky_placeholder" = "Paste a pubky";
"contacts__add_scan_qr" = "Scan QR";
"contacts__add_button" = "Add";
+"contacts__add_error_existing" = "This pubky is already in your contacts.";
"contacts__add_error_invalid_key" = "Invalid pubky key format. Please check and try again.";
"contacts__add_error_self" = "You can't add your own pubky as a contact.";
"contacts__add_retrieving" = "Retrieving\ncontact info";
@@ -1037,6 +1040,7 @@
"slashtags__error_deleting_profile" = "Unable To Delete Profile";
"slashtags__error_pay_title" = "Unable To Pay Contact";
"slashtags__error_pay_empty_msg" = "The contact you're trying to send to hasn't enabled payments.";
+"slashtags__error_pay_not_opened_msg" = "No compatible payment endpoint is available.";
"slashtags__auth_depricated_title" = "Deprecated";
"slashtags__auth_depricated_msg" = "Slashauth is deprecated. Please use Bitkit Beta.";
"profile__nav_title" = "Profile";
diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift
index d871d109..5874326d 100644
--- a/Bitkit/Services/CoreService.swift
+++ b/Bitkit/Services/CoreService.swift
@@ -395,6 +395,7 @@ class ActivityService {
var isTransfer = existingOnchain?.isTransfer ?? false
var channelId = existingOnchain?.channelId
let transferTxId = existingOnchain?.transferTxId
+ let contact = existingOnchain?.contact
let feeRate = existingOnchain?.feeRate ?? 1
let preservedAddress = existingOnchain?.address ?? "Loading..."
let doesExist = existingOnchain?.doesExist ?? true
@@ -466,6 +467,7 @@ class ActivityService {
confirmTimestamp: blockTimestamp,
channelId: channelId,
transferTxId: transferTxId,
+ contact: contact,
createdAt: UInt64(payment.creationTime.timeIntervalSince1970),
updatedAt: paymentTimestamp,
seenAt: seenAt
@@ -691,6 +693,7 @@ class ActivityService {
message: description ?? "",
timestamp: paymentTimestamp,
preimage: preimage,
+ contact: existingLightning?.contact,
createdAt: paymentTimestamp,
updatedAt: paymentTimestamp,
seenAt: existingLightning?.seenAt
@@ -960,6 +963,21 @@ class ActivityService {
}
}
+ func get(contact publicKey: String, sortDirection: SortDirection = .desc) async throws -> [Activity] {
+ let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey
+ // TODO: push contact filtering into BitkitCore once the activity store exposes it.
+ let activities = try await get(filter: .all, sortDirection: sortDirection)
+
+ return activities.filter { activity in
+ switch activity {
+ case let .lightning(lightning):
+ return PubkyPublicKeyFormat.matches(lightning.contact, normalizedKey)
+ case let .onchain(onchain):
+ return PubkyPublicKeyFormat.matches(onchain.contact, normalizedKey)
+ }
+ }
+ }
+
func update(id: String, activity: Activity) async throws {
try await ServiceQueue.background(.core) {
try updateActivity(activityId: id, activity: activity)
@@ -982,7 +1000,8 @@ class ActivityService {
address: String,
amount: UInt64,
fee: UInt64,
- feeRate: UInt32
+ feeRate: UInt32,
+ contact: String? = nil
) async {
do {
try await ServiceQueue.background(.core) {
@@ -1008,6 +1027,7 @@ class ActivityService {
confirmTimestamp: nil,
channelId: nil,
transferTxId: nil,
+ contact: contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 },
createdAt: now,
updatedAt: now,
seenAt: now
@@ -1022,6 +1042,32 @@ class ActivityService {
}
}
+ func setContact(_ publicKey: String?, forActivity id: String) async throws {
+ let normalizedContact = publicKey.map { PubkyPublicKeyFormat.normalized($0) ?? $0 }
+
+ try await ServiceQueue.background(.core) {
+ guard let activity = try getActivityById(activityId: id) else {
+ throw AppError(message: "Activity not found", debugMessage: "Activity with ID \(id) not found")
+ }
+
+ switch activity {
+ case var .lightning(lightning):
+ guard lightning.contact != normalizedContact else { return }
+ lightning.contact = normalizedContact
+ lightning.updatedAt = UInt64(Date().timeIntervalSince1970)
+ try updateActivity(activityId: id, activity: .lightning(lightning))
+ self.activitiesChangedSubject.send()
+
+ case var .onchain(onchain):
+ guard onchain.contact != normalizedContact else { return }
+ onchain.contact = normalizedContact
+ onchain.updatedAt = UInt64(Date().timeIntervalSince1970)
+ try updateActivity(activityId: id, activity: .onchain(onchain))
+ self.activitiesChangedSubject.send()
+ }
+ }
+ }
+
func delete(id: String) async throws -> Bool {
try await ServiceQueue.background(.core) {
// Rebuild cache if deleting an onchain activity with boostTxIds
@@ -1217,6 +1263,7 @@ class ActivityService {
message: template.message,
timestamp: timestamp,
preimage: template.status == .succeeded ? "preimage\(activityId)" : nil,
+ contact: nil,
createdAt: timestamp,
updatedAt: timestamp,
seenAt: nil
@@ -1241,6 +1288,7 @@ class ActivityService {
confirmTimestamp: template.confirmed == true ? timestamp + 3600 : nil,
channelId: nil,
transferTxId: nil,
+ contact: nil,
createdAt: timestamp,
updatedAt: timestamp,
seenAt: nil
diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift
index 13d84719..b3d3c5f3 100644
--- a/Bitkit/Services/MigrationsService.swift
+++ b/Bitkit/Services/MigrationsService.swift
@@ -1404,6 +1404,7 @@ extension MigrationsService {
message: item.message ?? "",
timestamp: timestampSecs,
preimage: item.preimage,
+ contact: nil,
createdAt: timestampSecs,
updatedAt: timestampSecs,
seenAt: now
@@ -1853,6 +1854,7 @@ extension MigrationsService {
confirmTimestamp: item.confirmTimestamp.map { UInt64($0 / 1000) },
channelId: item.channelId,
transferTxId: item.transferTxId,
+ contact: nil,
createdAt: activityTimestamp,
updatedAt: activityTimestamp,
seenAt: now
diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift
new file mode 100644
index 00000000..dd1b54d5
--- /dev/null
+++ b/Bitkit/Services/PublicPaykitService.swift
@@ -0,0 +1,488 @@
+import BitkitCore
+import Foundation
+import LDKNode
+
+enum PublicPaykitError: LocalizedError {
+ case noSupportedEndpoint
+ case walletNotReady
+ case invalidPayload
+
+ var errorDescription: String? {
+ switch self {
+ case .noSupportedEndpoint:
+ return "No supported public payment endpoint is available."
+ case .walletNotReady:
+ 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."
+ }
+ }
+}
+
+enum PublicPaykitPaymentLaunchResult {
+ case opened(paymentRequest: String)
+ case noEndpoint
+ case notOpened
+
+ var contactPaymentFailureMessageKey: String? {
+ switch self {
+ case .opened:
+ nil
+ case .noEndpoint:
+ "slashtags__error_pay_empty_msg"
+ case .notOpened:
+ "slashtags__error_pay_not_opened_msg"
+ }
+ }
+}
+
+private actor PublicPaykitEndpointLock {
+ private var isLocked = false
+ private var waiters: [CheckedContinuation] = []
+
+ func withLock(_ operation: () async throws -> T) async throws -> T {
+ await lock()
+ defer { unlock() }
+ return try await operation()
+ }
+
+ private func lock() async {
+ if !isLocked {
+ isLocked = true
+ return
+ }
+
+ await withCheckedContinuation { continuation in
+ waiters.append(continuation)
+ }
+ }
+
+ private func unlock() {
+ guard !waiters.isEmpty else {
+ isLocked = false
+ return
+ }
+
+ waiters.removeFirst().resume()
+ }
+}
+
+enum PublicPaykitService {
+ private static let endpointLock = PublicPaykitEndpointLock()
+ enum MethodId: String, Hashable, CaseIterable {
+ case bitcoinLightningBolt11 = "btc-lightning-bolt11"
+ case bitcoinLightningLnurl = "btc-lightning-lnurl"
+ case bitcoinOnchainP2tr = "btc-bitcoin-p2tr"
+ case bitcoinOnchainP2wpkh = "btc-bitcoin-p2wpkh"
+ case bitcoinOnchainP2sh = "btc-bitcoin-p2sh"
+ case bitcoinOnchainP2pkh = "btc-bitcoin-p2pkh"
+ case testnetOnchainP2tr = "btc-testnet-p2tr"
+ case testnetOnchainP2wpkh = "btc-testnet-p2wpkh"
+ case testnetOnchainP2sh = "btc-testnet-p2sh"
+ case testnetOnchainP2pkh = "btc-testnet-p2pkh"
+ case signetOnchainP2tr = "btc-signet-p2tr"
+ case signetOnchainP2wpkh = "btc-signet-p2wpkh"
+ case signetOnchainP2sh = "btc-signet-p2sh"
+ case signetOnchainP2pkh = "btc-signet-p2pkh"
+ case regtestOnchainP2tr = "btc-regtest-p2tr"
+ case regtestOnchainP2wpkh = "btc-regtest-p2wpkh"
+ case regtestOnchainP2sh = "btc-regtest-p2sh"
+ case regtestOnchainP2pkh = "btc-regtest-p2pkh"
+
+ static let payablePreferenceOrder: [MethodId] = [
+ .bitcoinLightningBolt11,
+ .bitcoinLightningLnurl,
+ ] + onchainPreferenceOrder
+
+ static let publishableMethodIds: [MethodId] = [
+ .bitcoinLightningBolt11,
+ ] + onchainPreferenceOrder
+
+ static let onchainPreferenceOrder: [MethodId] = [
+ .bitcoinOnchainP2tr,
+ .testnetOnchainP2tr,
+ .signetOnchainP2tr,
+ .regtestOnchainP2tr,
+ .bitcoinOnchainP2wpkh,
+ .testnetOnchainP2wpkh,
+ .signetOnchainP2wpkh,
+ .regtestOnchainP2wpkh,
+ .bitcoinOnchainP2sh,
+ .testnetOnchainP2sh,
+ .signetOnchainP2sh,
+ .regtestOnchainP2sh,
+ .bitcoinOnchainP2pkh,
+ .testnetOnchainP2pkh,
+ .signetOnchainP2pkh,
+ .regtestOnchainP2pkh,
+ ]
+
+ var onchainNetwork: LDKNode.Network? {
+ switch self {
+ case .bitcoinOnchainP2tr, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh, .bitcoinOnchainP2pkh:
+ .bitcoin
+ case .testnetOnchainP2tr, .testnetOnchainP2wpkh, .testnetOnchainP2sh, .testnetOnchainP2pkh:
+ .testnet
+ case .signetOnchainP2tr, .signetOnchainP2wpkh, .signetOnchainP2sh, .signetOnchainP2pkh:
+ .signet
+ case .regtestOnchainP2tr, .regtestOnchainP2wpkh, .regtestOnchainP2sh, .regtestOnchainP2pkh:
+ .regtest
+ case .bitcoinLightningBolt11, .bitcoinLightningLnurl:
+ nil
+ }
+ }
+
+ static func onchainMethodId(network: LDKNode.Network, scriptType: OnchainScriptType) -> MethodId {
+ switch (network, scriptType) {
+ case (.bitcoin, .p2tr): .bitcoinOnchainP2tr
+ case (.bitcoin, .p2wpkh): .bitcoinOnchainP2wpkh
+ case (.bitcoin, .p2sh): .bitcoinOnchainP2sh
+ case (.bitcoin, .p2pkh): .bitcoinOnchainP2pkh
+ case (.testnet, .p2tr): .testnetOnchainP2tr
+ case (.testnet, .p2wpkh): .testnetOnchainP2wpkh
+ case (.testnet, .p2sh): .testnetOnchainP2sh
+ case (.testnet, .p2pkh): .testnetOnchainP2pkh
+ case (.signet, .p2tr): .signetOnchainP2tr
+ case (.signet, .p2wpkh): .signetOnchainP2wpkh
+ case (.signet, .p2sh): .signetOnchainP2sh
+ case (.signet, .p2pkh): .signetOnchainP2pkh
+ case (.regtest, .p2tr): .regtestOnchainP2tr
+ case (.regtest, .p2wpkh): .regtestOnchainP2wpkh
+ case (.regtest, .p2sh): .regtestOnchainP2sh
+ case (.regtest, .p2pkh): .regtestOnchainP2pkh
+ }
+ }
+ }
+
+ enum OnchainScriptType {
+ case p2tr
+ case p2wpkh
+ case p2sh
+ case p2pkh
+ }
+
+ struct Endpoint: Equatable, Hashable {
+ let methodId: MethodId
+ let value: String
+ let min: String?
+ let max: String?
+ let rawPayload: String
+
+ var paymentRequest: String {
+ value
+ }
+ }
+
+ struct EndpointSyncPlan: Equatable {
+ let endpointsToSet: [Endpoint]
+ let methodIdsToRemove: [MethodId]
+ }
+
+ static func fetchPublicEndpoints(publicKey: String) async throws -> [Endpoint] {
+ let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey
+ let paymentEntries = try await PubkyService.getPaymentList(publicKey: normalizedKey)
+ var endpointsByMethodId: [MethodId: Endpoint] = [:]
+
+ for entry in paymentEntries {
+ guard let endpoint = parseEndpoint(methodId: entry.methodId, endpointData: entry.endpointData) else {
+ continue
+ }
+
+ endpointsByMethodId[endpoint.methodId] = endpoint
+ }
+
+ return MethodId.payablePreferenceOrder.compactMap { endpointsByMethodId[$0] }
+ }
+
+ static func parseEndpoint(methodId rawMethodId: String, endpointData: String) -> Endpoint? {
+ guard let methodId = MethodId(rawValue: rawMethodId) else {
+ return nil
+ }
+
+ guard let payload = parsePayload(endpointData) else {
+ return nil
+ }
+
+ return Endpoint(
+ methodId: methodId,
+ value: payload.value,
+ min: payload.min,
+ max: payload.max,
+ rawPayload: endpointData
+ )
+ }
+
+ static func serializePayload(value: String) throws -> String {
+ let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedValue.isEmpty else {
+ throw PublicPaykitError.invalidPayload
+ }
+
+ let payload = ["value": trimmedValue]
+ let data = try JSONSerialization.data(withJSONObject: payload)
+ guard let json = String(data: data, encoding: .utf8) else {
+ throw PublicPaykitError.invalidPayload
+ }
+ return json
+ }
+
+ @MainActor
+ static func syncPublishedEndpoints(wallet: WalletViewModel, publish: Bool) async throws {
+ guard publish else {
+ try await removePublishedEndpoints()
+ return
+ }
+
+ let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true)
+ try await applyPublishedEndpoints(desiredEndpoints)
+ }
+
+ @MainActor
+ static func syncCurrentPublishedEndpoints(wallet: WalletViewModel) async throws {
+ let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false)
+ try await applyPublishedEndpoints(desiredEndpoints)
+ }
+
+ static func removePublishedEndpoints() async throws {
+ try await endpointLock.withLock {
+ let existingMethodIds = try await currentPublishedMethodIds()
+
+ for methodId in methodIdsToRemoveWhenUnpublishing(existingMethodIds: existingMethodIds) {
+ try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue)
+ }
+ }
+ }
+
+ static func hasPayablePublicEndpoint(publicKey: String) async throws -> Bool {
+ let endpoints = try await payablePublicEndpoints(publicKey: publicKey)
+ return !endpoints.isEmpty
+ }
+
+ static func payablePublicEndpoints(publicKey: String) async throws -> [Endpoint] {
+ let endpoints = try await fetchPublicEndpoints(publicKey: publicKey)
+ return await payableEndpoints(from: endpoints)
+ }
+
+ static func payableEndpoints(from endpoints: [Endpoint]) async -> [Endpoint] {
+ var payableEndpoints: [Endpoint] = []
+
+ for endpoint in endpoints {
+ if await isPayableEndpoint(endpoint) {
+ payableEndpoints.append(endpoint)
+ }
+ }
+
+ return payableEndpoints
+ }
+
+ static func beginPayment(
+ to publicKey: String
+ ) async throws -> PublicPaykitPaymentLaunchResult {
+ let endpoints = try await fetchPublicEndpoints(publicKey: publicKey)
+ let payableEndpoints = await payableEndpoints(from: endpoints)
+
+ guard !payableEndpoints.isEmpty else {
+ return endpoints.isEmpty ? .noEndpoint : .notOpened
+ }
+
+ return .opened(paymentRequest: paymentRequest(from: payableEndpoints))
+ }
+
+ static func paymentRequest(from endpoints: [Endpoint]) -> String {
+ guard let onchainEndpoint = MethodId.onchainPreferenceOrder.compactMap({ methodId in endpoints.first { $0.methodId == methodId } }).first,
+ let bolt11Endpoint = endpoints.first(where: { $0.methodId == .bitcoinLightningBolt11 })
+ else {
+ return endpoints.first?.paymentRequest ?? ""
+ }
+
+ var allowedCharacters = CharacterSet.urlQueryAllowed
+ allowedCharacters.remove(charactersIn: "?&=")
+ let lightningPaymentRequest = bolt11Endpoint.paymentRequest
+ let encodedLightning = lightningPaymentRequest.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? lightningPaymentRequest
+ return "bitcoin:\(onchainEndpoint.paymentRequest)?lightning=\(encodedLightning)"
+ }
+
+ static func onchainMethodId(for address: String, network: LDKNode.Network = Env.network) -> MethodId {
+ let normalizedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ let scriptType: OnchainScriptType = if normalizedAddress.hasPrefix("bc1p") || normalizedAddress.hasPrefix("tb1p") || normalizedAddress
+ .hasPrefix("bcrt1p")
+ {
+ .p2tr
+ } else if normalizedAddress.hasPrefix("bc1q") || normalizedAddress.hasPrefix("tb1q") || normalizedAddress.hasPrefix("bcrt1q") {
+ .p2wpkh
+ } else if normalizedAddress.hasPrefix("3") || normalizedAddress.hasPrefix("2") {
+ .p2sh
+ } else {
+ .p2pkh
+ }
+
+ return MethodId.onchainMethodId(network: network, scriptType: scriptType)
+ }
+
+ static func methodIdsToRemoveWhenUnpublishing(existingMethodIds: Set) -> [MethodId] {
+ MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) }
+ }
+
+ static func publishedEndpointSyncPlan(existingEndpoints: [MethodId: String], desiredEndpoints: [Endpoint]) -> EndpointSyncPlan {
+ let desiredMethodIds = Set(desiredEndpoints.map(\.methodId))
+ return EndpointSyncPlan(
+ endpointsToSet: desiredEndpoints.filter { existingEndpoints[$0.methodId] != $0.rawPayload },
+ methodIdsToRemove: MethodId.publishableMethodIds.filter { existingEndpoints[$0] != nil && !desiredMethodIds.contains($0) }
+ )
+ }
+
+ private struct ParsedPayload {
+ let value: String
+ let min: String?
+ let max: String?
+ }
+
+ private static func parsePayload(_ endpointData: String) -> ParsedPayload? {
+ let trimmedPayload = endpointData.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedPayload.isEmpty else {
+ return nil
+ }
+
+ if let data = trimmedPayload.data(using: .utf8),
+ let payloadObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let value = (payloadObject["value"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !value.isEmpty
+ {
+ return ParsedPayload(
+ value: value,
+ min: payloadObject["min"] as? String,
+ max: payloadObject["max"] as? String
+ )
+ }
+
+ return nil
+ }
+
+ private static func applyPublishedEndpoints(_ desiredEndpoints: [Endpoint]) async throws {
+ try await endpointLock.withLock {
+ let existingEndpoints = try await currentPublishedEndpoints()
+ let plan = publishedEndpointSyncPlan(existingEndpoints: existingEndpoints, desiredEndpoints: desiredEndpoints)
+
+ for endpoint in plan.endpointsToSet {
+ try await PubkyService.setPaymentEndpoint(
+ methodId: endpoint.methodId.rawValue,
+ endpointData: endpoint.rawPayload
+ )
+ }
+
+ for methodId in plan.methodIdsToRemove {
+ try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue)
+ }
+ }
+ }
+
+ private static func currentPublishedMethodIds() async throws -> Set {
+ let endpoints = try await currentPublishedEndpoints()
+ return Set(endpoints.keys)
+ }
+
+ private static func currentPublishedEndpoints() async throws -> [MethodId: String] {
+ guard let publicKey = await PubkyService.currentPublicKey() else {
+ throw PubkyServiceError.sessionNotActive
+ }
+
+ let paymentEntries = try await PubkyService.getPaymentList(publicKey: publicKey)
+ var endpoints: [MethodId: String] = [:]
+ for entry in paymentEntries {
+ guard let methodId = MethodId(rawValue: entry.methodId) else {
+ continue
+ }
+
+ endpoints[methodId] = entry.endpointData
+ }
+
+ return endpoints
+ }
+
+ @MainActor
+ private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool) async throws -> [Endpoint] {
+ if refreshIfNeeded {
+ let isNodeReady = await wallet.waitForNodeToRun()
+ let lifecycleState = wallet.nodeLifecycleState
+ guard isNodeReady || lifecycleState == .running else {
+ throw PublicPaykitError.walletNotReady
+ }
+
+ _ = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: true)
+ }
+
+ let publicEndpoints = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: false)
+ var endpoints: [Endpoint] = []
+
+ let onchainAddress = publicEndpoints.onchainAddress.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !onchainAddress.isEmpty {
+ try endpoints.append(
+ Endpoint(
+ methodId: onchainMethodId(for: onchainAddress),
+ value: onchainAddress,
+ min: nil,
+ max: nil,
+ rawPayload: serializePayload(value: onchainAddress)
+ )
+ )
+ }
+
+ let bolt11 = publicEndpoints.bolt11.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !bolt11.isEmpty {
+ try endpoints.append(
+ Endpoint(
+ methodId: .bitcoinLightningBolt11,
+ value: bolt11,
+ min: nil,
+ max: nil,
+ rawPayload: serializePayload(value: bolt11)
+ )
+ )
+ }
+
+ guard !endpoints.isEmpty else {
+ throw PublicPaykitError.noSupportedEndpoint
+ }
+
+ return endpoints
+ }
+
+ private static func isPayableEndpoint(_ endpoint: Endpoint) async -> Bool {
+ switch endpoint.methodId {
+ case .bitcoinLightningBolt11:
+ guard case let .lightning(invoice) = try? await decode(invoice: endpoint.paymentRequest) else {
+ return false
+ }
+
+ guard !invoice.isExpired else {
+ return false
+ }
+
+ let invoiceNetwork = NetworkValidationHelper.convertNetworkType(invoice.networkType)
+ return !NetworkValidationHelper.isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network)
+
+ case .bitcoinLightningLnurl:
+ guard case .lnurlPay = try? await decode(invoice: endpoint.paymentRequest) else {
+ return false
+ }
+
+ return true
+
+ case .bitcoinOnchainP2tr, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh, .bitcoinOnchainP2pkh,
+ .testnetOnchainP2tr, .testnetOnchainP2wpkh, .testnetOnchainP2sh, .testnetOnchainP2pkh,
+ .signetOnchainP2tr, .signetOnchainP2wpkh, .signetOnchainP2sh, .signetOnchainP2pkh,
+ .regtestOnchainP2tr, .regtestOnchainP2wpkh, .regtestOnchainP2sh, .regtestOnchainP2pkh:
+ guard endpoint.methodId.onchainNetwork == Env.network else {
+ return false
+ }
+
+ guard case let .onChain(invoice) = try? await decode(invoice: endpoint.paymentRequest) else {
+ return false
+ }
+
+ let addressValidation = try? validateBitcoinAddress(address: invoice.address)
+ let addressNetwork = addressValidation.map { NetworkValidationHelper.convertNetworkType($0.network) }
+ return !NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network)
+ }
+ }
+}
diff --git a/Bitkit/Services/ZipService.swift b/Bitkit/Services/ZipService.swift
index 1a812bc4..29e84184 100644
--- a/Bitkit/Services/ZipService.swift
+++ b/Bitkit/Services/ZipService.swift
@@ -64,12 +64,14 @@ extension FileToZip {
}
private static func sanitizedFilename(_ filename: String) -> String {
- filename
+ let sanitized = filename
.replacingOccurrences(of: "\\", with: "/")
.split(separator: "/")
.last
.map(String.init)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+
+ return sanitized == "." || sanitized == ".." ? "" : sanitized
}
}
diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift
index ac5de593..a93fc784 100644
--- a/Bitkit/Utilities/AppReset.swift
+++ b/Bitkit/Utilities/AppReset.swift
@@ -9,6 +9,8 @@ enum AppReset {
session: SessionManager,
toastType: Toast.ToastType = .success
) async throws {
+ await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe")
+
// Set wiping flag to prevent backups during wipe operations
BackupService.shared.setWiping(true)
defer {
diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift
index 84b351a5..a9c30fd1 100644
--- a/Bitkit/Utilities/PaymentNavigationHelper.swift
+++ b/Bitkit/Utilities/PaymentNavigationHelper.swift
@@ -146,4 +146,38 @@ struct PaymentNavigationHelper {
// No valid invoice data
return nil
}
+
+ static func contactPaymentRoute(
+ app: AppViewModel,
+ currency: CurrencyViewModel,
+ settings: SettingsViewModel
+ ) -> SendRoute? {
+ guard let route = appropriateSendRoute(app: app, currency: currency, settings: settings) else {
+ return nil
+ }
+
+ switch route {
+ case .quickpay:
+ if let lnurlPayData = app.lnurlPayData {
+ return lnurlPayData.isFixedAmount ? .lnurlPayConfirm : .lnurlPayAmount
+ }
+
+ if let invoice = app.scannedLightningInvoice {
+ return invoice.amountSatoshis == 0 ? .amount : .confirm
+ }
+
+ if app.scannedOnchainInvoice != nil {
+ return .amount
+ }
+
+ return route
+ case .confirm:
+ if let invoice = app.scannedLightningInvoice {
+ return invoice.amountSatoshis == 0 ? .amount : .confirm
+ }
+ return route
+ default:
+ return route
+ }
+ }
}
diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift
index de18a427..944c4473 100644
--- a/Bitkit/ViewModels/ActivityListViewModel.swift
+++ b/Bitkit/ViewModels/ActivityListViewModel.swift
@@ -47,6 +47,10 @@ class ActivityListViewModel: ObservableObject {
@Published private(set) var availableTags: [String] = []
+ var activitiesChangedPublisher: AnyPublisher {
+ coreService.activity.activitiesChangedPublisher
+ }
+
private func updateAvailableTags() async {
do {
availableTags = try await coreService.activity.allPossibleTags()
@@ -133,7 +137,7 @@ class ActivityListViewModel: ObservableObject {
func syncState() async {
do {
// Get latest activities first as that's displayed on the home view
- let limitLatest: UInt32 = 4
+ let limitLatest = UInt32(ActivityDisplayConstants.maxHomeActivityItems)
// Fetch extra to account for potential filtering of replaced transactions
let latest = try await coreService.activity.get(filter: .all, limit: limitLatest * 3)
let filtered = await filterOutReplacedSentTransactions(latest)
@@ -268,6 +272,20 @@ class ActivityListViewModel: ObservableObject {
return activity
}
+ func contactActivities(publicKey: String) async throws -> [Activity] {
+ let activities = try await coreService.activity.get(contact: publicKey, sortDirection: .desc)
+ return await filterOutReplacedSentTransactions(activities)
+ }
+
+ func setContact(_ contactPublicKey: String, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws {
+ if syncLdkPayments {
+ try? await syncLdkNodePayments()
+ }
+
+ try await coreService.activity.setContact(contactPublicKey, forActivity: paymentId)
+ await syncState()
+ }
+
func getAllPossibleTags() async throws -> [String] {
try await coreService.activity.allPossibleTags()
}
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 07dc3be4..75252920 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -10,6 +10,10 @@ struct SendSheetPendingResolution: Equatable {
let success: Bool
}
+struct ContactPaymentContext: Equatable {
+ let publicKey: String
+}
+
enum ManualEntryValidationResult: Equatable {
case valid
case empty
@@ -28,6 +32,7 @@ class AppViewModel: ObservableObject {
@Published var manualEntryInput: String = ""
@Published var isManualEntryInputValid: Bool = false
@Published var manualEntryValidationResult: ManualEntryValidationResult = .empty
+ @Published var contactPaymentContext: ContactPaymentContext?
// LNURL
@Published var lnurlPayData: LnurlPayData?
@@ -62,6 +67,7 @@ class AppViewModel: ObservableObject {
/// Payment hashes for which we navigated to the pending screen.
/// When payment succeeds/fails, we show toast and publish resolution so SendPendingScreen can navigate.
private var pendingPaymentHashes: Set = []
+ private var pendingContactPaymentContexts: [String: ContactPaymentContext] = [:]
/// When a payment that was shown on the pending screen succeeds or fails, this is set so SendPendingScreen can navigate.
/// Consumed by SendPendingScreen via consumeSendSheetPendingResolution.
@@ -288,8 +294,25 @@ extension AppViewModel {
// MARK: Pending payment tracking
extension AppViewModel {
- func addPendingPaymentHash(_ hash: String) {
+ func addPendingPaymentHash(_ hash: String, contactPublicKey: String? = nil) {
pendingPaymentHashes.insert(hash)
+
+ if let contactPublicKey {
+ pendingContactPaymentContexts[hash] = ContactPaymentContext(publicKey: contactPublicKey)
+ }
+ }
+
+ func addPendingContactPaymentContext(_ hash: String, contactPublicKey: String?) {
+ guard let contactPublicKey else { return }
+ pendingContactPaymentContexts[hash] = ContactPaymentContext(publicKey: contactPublicKey)
+ }
+
+ func contactPaymentContext(forPendingPaymentHash hash: String) -> ContactPaymentContext? {
+ pendingContactPaymentContexts[hash]
+ }
+
+ func consumeContactPaymentContext(forPendingPaymentHash hash: String) {
+ pendingContactPaymentContexts.removeValue(forKey: hash)
}
/// Called by SendPendingScreen when it consumes a resolution. Clears the published value.
@@ -586,6 +609,7 @@ extension AppViewModel {
selectedWalletToPayFrom = .onchain // Reset to default
lnurlPayData = nil
lnurlWithdrawData = nil
+ contactPaymentContext = nil
}
}
@@ -774,6 +798,7 @@ extension AppViewModel {
message: "",
timestamp: now,
preimage: nil,
+ contact: nil,
createdAt: now,
updatedAt: nil,
seenAt: nil
diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift
index c26ea2e4..a2995bda 100644
--- a/Bitkit/ViewModels/NavigationViewModel.swift
+++ b/Bitkit/ViewModels/NavigationViewModel.swift
@@ -12,6 +12,7 @@ enum Route: Hashable {
case contacts
case contactsIntro
case contactDetail(publicKey: String)
+ case contactActivity(publicKey: String)
case contactImportOverview
case contactImportSelect
case addContact(publicKey: String)
diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift
index 782b82a1..ee91fee6 100644
--- a/Bitkit/ViewModels/WalletViewModel.swift
+++ b/Bitkit/ViewModels/WalletViewModel.swift
@@ -17,6 +17,9 @@ class WalletViewModel: ObservableObject {
@AppStorage("onchainAddress") var onchainAddress = ""
@AppStorage("bolt11") var bolt11 = ""
@AppStorage("bip21") var bip21 = ""
+ @AppStorage("publicPaykitBolt11") var publicPaykitBolt11 = ""
+ @AppStorage("publicPaykitBolt11PaymentHash") var publicPaykitBolt11PaymentHash = ""
+ @AppStorage("publicPaykitBolt11ExpiresAt") var publicPaykitBolt11ExpiresAt = 0.0
@AppStorage("channelCount") var channelCount: Int = 0 // Keeping a cached version of this so we can better anticipate the receive flow UI
// Send flow
@@ -56,6 +59,9 @@ class WalletViewModel: ObservableObject {
private var probeOutcomes: [PaymentId: ProbeOutcome] = [:]
@AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false
+ @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false
+
+ private static let publicPaykitInvoiceRefreshBufferSeconds: TimeInterval = 30 * 60
private let lightningService: LightningService
private let coreService: CoreService
@@ -200,7 +206,14 @@ class WalletViewModel: ObservableObject {
paymentHash: paymentHash,
shortChannelId: shortChannelId
)
- case .paymentReceived, .channelReady:
+ case let .paymentReceived(_, paymentHash, _, _):
+ self.bolt11 = ""
+ self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash)
+ Task {
+ await self.refreshAndSyncState()
+ try? await self.refreshBip21()
+ }
+ case .channelReady:
self.bolt11 = ""
Task {
await self.refreshAndSyncState()
@@ -928,16 +941,8 @@ class WalletViewModel: ObservableObject {
return channels?.contains(where: \.isUsable) ?? false
}
- func refreshBip21(forceRefreshBolt11: Bool = false) async throws {
- // Get old payment ID and tags before refreshing (which may change payment ID)
- let oldPaymentId = await paymentId()
- var tagsToMigrate: [String] = []
- if let oldPaymentId, !oldPaymentId.isEmpty {
- if let oldMetadata = try? await coreService.activity.getPreActivityMetadata(searchKey: oldPaymentId, searchByAddress: false) {
- tagsToMigrate = oldMetadata.tags
- }
- }
-
+ @discardableResult
+ private func refreshReusableOnchainAddress() async throws -> String {
if onchainAddress.isEmpty {
onchainAddress = try await lightningService.newAddress()
} else {
@@ -950,6 +955,81 @@ class WalletViewModel: ObservableObject {
}
}
+ return onchainAddress
+ }
+
+ func refreshPublicPaykitEndpoints(forceRefreshBolt11: Bool = false) async throws -> (onchainAddress: String, bolt11: String) {
+ let publicOnchainAddress = try await refreshReusableOnchainAddress()
+
+ if hasReadyChannels {
+ let hasReusableInvoice = await hasReusablePublicPaykitInvoice()
+ let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice
+ if shouldRefreshBolt11 {
+ try await refreshPublicPaykitBolt11()
+ }
+ } else {
+ clearPublicPaykitBolt11()
+ }
+
+ return (publicOnchainAddress, publicPaykitBolt11)
+ }
+
+ func refreshPublicPaykitEndpointsOnForeground() async {
+ guard sharesPublicPaykitEndpoints else { return }
+
+ do {
+ try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self)
+ } catch {
+ Logger.warn("Failed to refresh public Paykit endpoints on foreground: \(error)", context: "WalletViewModel")
+ }
+ }
+
+ private func hasReusablePublicPaykitInvoice() async -> Bool {
+ guard !publicPaykitBolt11.isEmpty else { return false }
+ 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
+ }
+
+ private func refreshPublicPaykitBolt11() async throws {
+ let invoice = try await createInvoice(amountSats: nil, note: "")
+ guard case let .lightning(lightningInvoice) = try await decode(invoice: invoice) else {
+ clearPublicPaykitBolt11()
+ throw PublicPaykitError.invalidPayload
+ }
+
+ publicPaykitBolt11 = invoice
+ publicPaykitBolt11PaymentHash = lightningInvoice.paymentHash.hex
+ publicPaykitBolt11ExpiresAt = Double(lightningInvoice.timestampSeconds + lightningInvoice.expirySeconds)
+ }
+
+ private func clearPublicPaykitBolt11() {
+ publicPaykitBolt11 = ""
+ publicPaykitBolt11PaymentHash = ""
+ publicPaykitBolt11ExpiresAt = 0
+ }
+
+ private func rotatePublicPaykitInvoiceIfNeeded(paymentHash: String) {
+ guard !publicPaykitBolt11PaymentHash.isEmpty,
+ publicPaykitBolt11PaymentHash == paymentHash
+ else { return }
+
+ clearPublicPaykitBolt11()
+ }
+
+ func refreshBip21(forceRefreshBolt11: Bool = false) async throws {
+ // Get old payment ID and tags before refreshing (which may change payment ID)
+ let oldPaymentId = await paymentId()
+ var tagsToMigrate: [String] = []
+ if let oldPaymentId, !oldPaymentId.isEmpty {
+ if let oldMetadata = try? await coreService.activity.getPreActivityMetadata(searchKey: oldPaymentId, searchByAddress: false) {
+ tagsToMigrate = oldMetadata.tags
+ }
+ }
+
+ try await refreshReusableOnchainAddress()
+
var newBip21 = "bitcoin:\(onchainAddress)"
let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil
@@ -992,6 +1072,14 @@ class WalletViewModel: ObservableObject {
// Persist metadata with migrated tags
await persistPreActivityMetadata(tags: tagsToMigrate)
+
+ if sharesPublicPaykitEndpoints {
+ do {
+ try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self)
+ } catch {
+ Logger.warn("Failed to refresh public paykit endpoints after receive refresh: \(error)", context: "WalletViewModel")
+ }
+ }
}
/// Payment hash from the current bolt11 invoice, if available
@@ -1154,6 +1242,7 @@ class WalletViewModel: ObservableObject {
onchainAddress = ""
bolt11 = ""
bip21 = ""
+ clearPublicPaykitBolt11()
try? await coreService.activity.removeAll()
diff --git a/Bitkit/Views/Contacts/AddContactSheet.swift b/Bitkit/Views/Contacts/AddContactSheet.swift
index e89bd8fc..b33d6992 100644
--- a/Bitkit/Views/Contacts/AddContactSheet.swift
+++ b/Bitkit/Views/Contacts/AddContactSheet.swift
@@ -4,13 +4,14 @@ struct AddContactSheet: View {
@Environment(\.dismiss) private var dismiss
let currentPublicKey: String?
+ let contacts: [PubkyContact]
let onAdd: (String) -> Void
let onScanQR: () -> Void
@State private var pubkyInput: String = ""
private var validationResult: AddContactValidationResult {
- resolveAddContactValidation(input: pubkyInput, ownPublicKey: currentPublicKey)
+ resolveAddContactValidation(input: pubkyInput, ownPublicKey: currentPublicKey, existingContacts: contacts)
}
private var validationMessage: String? {
@@ -116,7 +117,7 @@ struct AddContactSheet: View {
#Preview {
Color.clear
.sheet(isPresented: .constant(true)) {
- AddContactSheet(currentPublicKey: nil, onAdd: { _ in }, onScanQR: {})
+ AddContactSheet(currentPublicKey: nil, contacts: [], onAdd: { _ in }, onScanQR: {})
}
.preferredColorScheme(.dark)
}
diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift
index 8a8ba0a3..4004ad38 100644
--- a/Bitkit/Views/Contacts/AddContactView.swift
+++ b/Bitkit/Views/Contacts/AddContactView.swift
@@ -2,17 +2,21 @@ import SwiftUI
struct AddContactView: View {
@EnvironmentObject var app: AppViewModel
+ @EnvironmentObject var currency: CurrencyViewModel
@EnvironmentObject var navigation: NavigationViewModel
@EnvironmentObject var contactsManager: ContactsManager
@EnvironmentObject var pubkyProfile: PubkyProfileManager
+ @EnvironmentObject var settings: SettingsViewModel
+ @EnvironmentObject var sheets: SheetViewModel
let publicKey: String
@State private var fetchedProfile: PubkyProfile?
+ @State private var hasPublicPaymentEndpoint = false
@State private var isLoading = true
@State private var isSaving = false
@State private var errorMessage: String?
- @State private var canRetry = true
+ @State private var canRetryError = true
private var truncatedPublicKey: String {
let displayKey = normalizedPublicKey ?? publicKey
@@ -21,7 +25,11 @@ struct AddContactView: View {
}
private var normalizedPublicKey: String? {
- if case let .valid(normalizedKey) = resolveAddContactValidation(input: publicKey, ownPublicKey: pubkyProfile.publicKey) {
+ if case let .valid(normalizedKey) = resolveAddContactValidation(
+ input: publicKey,
+ ownPublicKey: pubkyProfile.publicKey,
+ existingContacts: contactsManager.contacts
+ ) {
return normalizedKey
}
@@ -33,7 +41,7 @@ struct AddContactView: View {
NavigationBar(title: t("contacts__add_title"))
.padding(.horizontal, 16)
- if isLoading && fetchedProfile == nil {
+ if isLoading {
loadingContent
} else if let profile = fetchedProfile {
resultContent(profile)
@@ -53,22 +61,13 @@ struct AddContactView: View {
@State private var dashedCircleRotation: Double = 0
- @ViewBuilder
private var loadingContent: some View {
VStack(spacing: 0) {
CaptionMText(truncatedPublicKey, textColor: .white64)
.padding(.top, 24)
.padding(.bottom, 16)
- Circle()
- .fill(Color.white.opacity(0.1))
- .frame(width: 80, height: 80)
- .overlay {
- Text(String(publicKey.prefix(1)).uppercased())
- .font(Fonts.bold(size: 28))
- .foregroundColor(.textPrimary)
- }
- .accessibilityHidden(true)
+ ContactAvatarLetter(source: publicKey, size: 80)
.padding(.bottom, 24)
DisplayText(t("contacts__add_retrieving"), accentColor: .pubkyGreen)
@@ -92,7 +91,6 @@ struct AddContactView: View {
}
}
- @ViewBuilder
private var retrievingAnimation: some View {
ZStack {
Image("ellipse-outer-green")
@@ -116,7 +114,6 @@ struct AddContactView: View {
// MARK: - Result State
- @ViewBuilder
private func resultContent(_ profile: PubkyProfile) -> some View {
VStack(spacing: 0) {
ScrollView {
@@ -144,6 +141,15 @@ struct AddContactView: View {
.padding(.horizontal, 32)
.padding(.bottom, 16)
+ if hasPublicPaymentEndpoint {
+ CustomButton(title: t("wallet__send"), variant: .secondary) {
+ await payContact()
+ }
+ .accessibilityIdentifier("AddContactPay")
+ .padding(.horizontal, 32)
+ .padding(.bottom, 16)
+ }
+
HStack(spacing: 16) {
CustomButton(title: t("common__discard"), variant: .secondary) {
navigation.navigateBack()
@@ -163,7 +169,6 @@ struct AddContactView: View {
// MARK: - Error State
- @ViewBuilder
private var errorContent: some View {
VStack(spacing: 16) {
Spacer()
@@ -173,17 +178,15 @@ struct AddContactView: View {
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
- if canRetry {
- CustomButton(title: t("profile__retry_load"), variant: .secondary) {
+ CustomButton(title: canRetryError ? t("common__retry") : t("common__discard"), variant: .secondary) {
+ if canRetryError {
await loadProfile()
- }
- .accessibilityIdentifier("AddContactRetry")
- } else {
- CustomButton(title: t("common__discard"), variant: .secondary) {
+ } else {
navigation.navigateBack()
}
- .accessibilityIdentifier("AddContactDiscardInvalid")
}
+ .accessibilityIdentifier(canRetryError ? "AddContactRetry" : "AddContactDiscard")
+
Spacer()
}
.padding(.horizontal, 32)
@@ -196,17 +199,26 @@ struct AddContactView: View {
isLoading = true
fetchedProfile = nil
errorMessage = nil
- canRetry = true
+ canRetryError = true
- switch resolveAddContactValidation(input: publicKey, ownPublicKey: pubkyProfile.publicKey) {
+ switch resolveAddContactValidation(
+ input: publicKey,
+ ownPublicKey: pubkyProfile.publicKey,
+ existingContacts: contactsManager.contacts
+ ) {
case .empty, .invalidKey:
errorMessage = t("contacts__add_error_invalid_key")
- canRetry = false
+ canRetryError = false
isLoading = false
return
case .ownKey:
errorMessage = t("contacts__add_error_self")
- canRetry = false
+ canRetryError = false
+ isLoading = false
+ return
+ case .existingContact:
+ errorMessage = t("contacts__add_error_existing")
+ canRetryError = false
isLoading = false
return
case let .valid(normalizedKey):
@@ -215,6 +227,7 @@ struct AddContactView: View {
} else {
errorMessage = t("contacts__add_error")
}
+ await loadPaymentEndpoints(publicKey: normalizedKey)
}
isLoading = false
@@ -244,15 +257,86 @@ struct AddContactView: View {
app.toast(type: .error, title: t("contacts__add_error"), description: error.localizedDescription)
}
}
+
+ private func loadPaymentEndpoints(publicKey: String) async {
+ do {
+ hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey)
+ } catch {
+ Logger.warn(
+ "Failed to load public payment endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)",
+ context: "AddContactView"
+ )
+ hasPublicPaymentEndpoint = false
+ }
+ }
+
+ private func payContact() async {
+ guard let normalizedPublicKey else {
+ app.toast(type: .warning, title: t("slashtags__error_pay_title"), description: t("slashtags__error_pay_empty_msg"))
+ return
+ }
+
+ do {
+ let result = try await PublicPaykitService.beginPayment(to: normalizedPublicKey)
+
+ 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
+ }
+ 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 public pubky \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "AddContactView")
+ 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: "AddContactView")
+ return false
+ }
+
+ guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else {
+ return false
+ }
+
+ app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey)
+ sheets.showSheet(.send, data: SendConfig(view: route))
+ return true
+ }
}
#Preview {
NavigationStack {
AddContactView(publicKey: "pubkyz6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK")
.environmentObject(AppViewModel())
+ .environmentObject(CurrencyViewModel())
.environmentObject(NavigationViewModel())
.environmentObject(ContactsManager())
.environmentObject(PubkyProfileManager())
+ .environmentObject(SettingsViewModel.shared)
+ .environmentObject(SheetViewModel())
}
.preferredColorScheme(.dark)
}
diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift
new file mode 100644
index 00000000..78f47995
--- /dev/null
+++ b/Bitkit/Views/Contacts/ContactActivityView.swift
@@ -0,0 +1,158 @@
+import BitkitCore
+import SwiftUI
+
+struct ContactActivityView: View {
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var activityList: ActivityListViewModel
+ @EnvironmentObject private var contactsManager: ContactsManager
+ @EnvironmentObject private var feeEstimatesManager: FeeEstimatesManager
+
+ let publicKey: String
+
+ @State private var activities: [Activity] = []
+ @State private var isLoading = true
+ @State private var hasError = false
+ @State private var contactName = ""
+
+ private var groupedActivities: [ActivityGroupItem] {
+ activityList.groupActivities(activities)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ NavigationBar(title: contactName.isEmpty ? t("wallet__activity") : contactName)
+ .padding(.horizontal, 16)
+
+ if isLoading {
+ loadingContent
+ } else if hasError {
+ errorContent
+ } else if groupedActivities.isEmpty {
+ emptyContent
+ } else {
+ activityContent
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.customBlack)
+ .navigationBarHidden(true)
+ .task {
+ resolveContactName()
+ await loadActivities(showLoading: true)
+ }
+ .onReceive(activityList.activitiesChangedPublisher) { _ in
+ Task {
+ await loadActivities(showLoading: activities.isEmpty)
+ }
+ }
+ .onReceive(contactsManager.$contacts) { _ in
+ resolveContactName()
+ }
+ }
+
+ private var activityContent: some View {
+ ScrollView(showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 16) {
+ ForEach(Array(zip(groupedActivities.indices, groupedActivities)), id: \.1) { index, groupItem in
+ switch groupItem {
+ case let .header(title):
+ CaptionMText(title)
+ .frame(height: 34, alignment: .bottom)
+
+ case let .activity(activity):
+ NavigationLink(value: Route.activityDetail(activity)) {
+ ActivityRow(
+ item: activity,
+ feeEstimates: feeEstimatesManager.estimates,
+ contact: activityContact
+ )
+ }
+ .accessibilityIdentifier("ContactActivity-\(index)")
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .bottomSafeAreaPadding()
+ }
+ }
+
+ private var loadingContent: some View {
+ VStack {
+ Spacer()
+ ActivityIndicator(size: 32)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private var emptyContent: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ BodyMText(t("wallet__activity_no"))
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private var errorContent: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ BodyMText(t("contacts__error_loading"))
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private var contactDisplayName: String {
+ if !contactName.isEmpty {
+ return contactName
+ }
+
+ return publicKey.ellipsis(maxLength: 18)
+ }
+
+ private var activityContact: PubkyContact {
+ PubkyContact(
+ publicKey: publicKey,
+ profile: PubkyProfile(
+ publicKey: publicKey,
+ name: contactDisplayName,
+ bio: "",
+ imageUrl: nil,
+ links: [],
+ status: nil
+ )
+ )
+ }
+
+ private func resolveContactName() {
+ contactName = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) })?.displayName ?? ""
+ }
+
+ private func loadActivities(showLoading: Bool) async {
+ if showLoading {
+ isLoading = true
+ }
+ defer {
+ if showLoading {
+ isLoading = false
+ }
+ }
+
+ do {
+ activities = try await activityList.contactActivities(publicKey: publicKey)
+ hasError = false
+ } catch {
+ Logger.error(error, context: "ContactActivityView")
+ if showLoading || activities.isEmpty {
+ activities = []
+ hasError = true
+ } else {
+ hasError = false
+ }
+ app.toast(type: .error, title: t("contacts__error_loading"), description: error.localizedDescription)
+ }
+ }
+}
diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift
index b021dca3..d96b8a28 100644
--- a/Bitkit/Views/Contacts/ContactDetailView.swift
+++ b/Bitkit/Views/Contacts/ContactDetailView.swift
@@ -2,12 +2,16 @@ import SwiftUI
struct ContactDetailView: View {
@EnvironmentObject var app: AppViewModel
+ @EnvironmentObject var currency: CurrencyViewModel
@EnvironmentObject var navigation: NavigationViewModel
@EnvironmentObject var contactsManager: ContactsManager
+ @EnvironmentObject var settings: SettingsViewModel
+ @EnvironmentObject var sheets: SheetViewModel
let publicKey: String
@State private var profile: PubkyProfile?
+ @State private var hasPublicPaymentEndpoint = false
@State private var isLoading = true
@State private var showAddTagSheet = false
@State private var hasResolvedContactFromContacts = false
@@ -17,7 +21,7 @@ struct ContactDetailView: View {
NavigationBar(title: t("contacts__detail_title"))
.padding(.horizontal, 16)
- if isLoading && profile == nil {
+ if isLoading {
loadingContent
} else if let profile {
contactBody(profile)
@@ -33,6 +37,7 @@ struct ContactDetailView: View {
profile = cached.profile
hasResolvedContactFromContacts = true
}
+ await loadPaymentEndpoints()
isLoading = false
}
.onReceive(contactsManager.$contacts) { updatedContacts in
@@ -49,7 +54,6 @@ struct ContactDetailView: View {
// MARK: - Contact Body
- @ViewBuilder
private func contactBody(_ profile: PubkyProfile) -> some View {
ScrollView {
VStack(spacing: 0) {
@@ -88,9 +92,22 @@ struct ContactDetailView: View {
// MARK: - Action Buttons
- @ViewBuilder
private var contactActions: some View {
HStack(spacing: 16) {
+ if hasPublicPaymentEndpoint {
+ GradientCircleButton(icon: "coins", accessibilityLabel: t("wallet__send")) {
+ Task {
+ await payContact()
+ }
+ }
+ .accessibilityIdentifier("ContactPay")
+ }
+
+ GradientCircleButton(icon: "activity", accessibilityLabel: t("wallet__activity")) {
+ navigation.navigate(.contactActivity(publicKey: publicKey))
+ }
+ .accessibilityIdentifier("ContactActivity")
+
GradientCircleButton(icon: "copy", accessibilityLabel: t("common__copy")) {
UIPasteboard.general.string = publicKey
app.toast(type: .success, title: t("common__copied"))
@@ -111,7 +128,6 @@ struct ContactDetailView: View {
// MARK: - Links / Metadata
- @ViewBuilder
private func linksSection(_ profile: PubkyProfile) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(profile.links.enumerated()), id: \.element.id) { index, link in
@@ -122,7 +138,6 @@ struct ContactDetailView: View {
// MARK: - Tags
- @ViewBuilder
private func tagsSection(_ profile: PubkyProfile) -> some View {
VStack(alignment: .leading, spacing: 8) {
CaptionMText(t("profile__create_tags_label"), textColor: .white64)
@@ -140,7 +155,6 @@ struct ContactDetailView: View {
}
}
- @ViewBuilder
private var addTagButton: some View {
IconActionButton(
icon: "tag",
@@ -203,7 +217,6 @@ struct ContactDetailView: View {
// MARK: - Loading & Empty States
- @ViewBuilder
private var loadingContent: some View {
VStack {
Spacer()
@@ -213,17 +226,20 @@ struct ContactDetailView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
- @ViewBuilder
private var emptyContent: some View {
VStack(spacing: 16) {
Spacer()
BodyMText(t("contacts__detail_empty_state"))
CustomButton(title: t("profile__retry_load"), variant: .secondary) {
+ isLoading = true
+ defer { isLoading = false }
+
if let contact = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) {
profile = contact.profile
} else if let fetched = await contactsManager.fetchContactProfile(publicKey: publicKey) {
profile = fetched
}
+ await loadPaymentEndpoints()
}
.accessibilityIdentifier("ContactRetry")
Spacer()
@@ -251,14 +267,80 @@ struct ContactDetailView: View {
presentingVC.present(activityVC, animated: true)
}
}
+
+ private func loadPaymentEndpoints() async {
+ do {
+ hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey)
+ } catch {
+ Logger.warn(
+ "Failed to load public payment endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)",
+ context: "ContactDetailView"
+ )
+ hasPublicPaymentEndpoint = false
+ }
+ }
+
+ private func payContact() async {
+ do {
+ let result = try await PublicPaykitService.beginPayment(to: publicKey)
+
+ 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
+ }
+ 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(publicKey)): \(error)", context: "ContactDetailView")
+ app.toast(
+ type: .error,
+ title: t("slashtags__error_pay_title"),
+ description: error.localizedDescription
+ )
+ }
+ }
+
+ @MainActor
+ private func openContactPayment(paymentRequest: String) async -> Bool {
+ do {
+ try await app.handleScannedData(paymentRequest)
+ } catch {
+ Logger.warn("Failed to decode contact payment request: \(error)", context: "ContactDetailView")
+ return false
+ }
+
+ guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else {
+ return false
+ }
+
+ app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey)
+ sheets.showSheet(.send, data: SendConfig(view: route))
+ return true
+ }
}
#Preview {
NavigationStack {
ContactDetailView(publicKey: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK")
.environmentObject(AppViewModel())
+ .environmentObject(CurrencyViewModel())
.environmentObject(NavigationViewModel())
.environmentObject(ContactsManager())
+ .environmentObject(SettingsViewModel.shared)
+ .environmentObject(SheetViewModel())
}
.preferredColorScheme(.dark)
}
diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift
index bf1a1479..3121e9a7 100644
--- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift
+++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift
@@ -56,7 +56,6 @@ struct ContactImportOverviewView: View {
// MARK: - Profile Row
- @ViewBuilder
private var profileRow: some View {
HStack(alignment: .top, spacing: 16) {
HeadlineText(profile.name)
@@ -67,14 +66,7 @@ struct ContactImportOverviewView: View {
if let imageUrl = profile.imageUrl {
PubkyImage(uri: imageUrl, size: 64)
} else {
- Circle()
- .fill(Color.pubkyGreen)
- .frame(width: 64, height: 64)
- .overlay {
- Text(String(profile.name.prefix(1)).uppercased())
- .font(Fonts.bold(size: 22))
- .foregroundColor(.textPrimary)
- }
+ ContactAvatarLetter(source: profile.name, size: 64, backgroundColor: .pubkyGreen)
}
}
.accessibilityHidden(true)
@@ -84,7 +76,6 @@ struct ContactImportOverviewView: View {
// MARK: - Contacts Summary
- @ViewBuilder
private var contactsSummary: some View {
HStack(spacing: 16) {
BodyMSBText(t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"]))
@@ -112,9 +103,7 @@ struct ContactImportOverviewView: View {
.fill(Color.gray4)
.frame(width: 36, height: 36)
.overlay {
- Text("+\(overflow)")
- .font(Fonts.bold(size: 12))
- .foregroundColor(.textPrimary)
+ CaptionBText("+\(overflow)", textColor: .textPrimary)
}
.overlay(
Circle()
@@ -136,24 +125,12 @@ struct ContactImportOverviewView: View {
.stroke(Color.customBlack, lineWidth: 2)
)
} else {
- Circle()
- .fill(Color.white.opacity(0.1))
- .frame(width: 36, height: 36)
- .overlay {
- Text(String(contact.displayName.prefix(1)).uppercased())
- .font(Fonts.bold(size: 13))
- .foregroundColor(.textPrimary)
- }
- .overlay(
- Circle()
- .stroke(Color.customBlack, lineWidth: 2)
- )
+ ContactAvatarLetter(source: contact.displayName, size: 36, strokeColor: .customBlack, strokeWidth: 2)
}
}
// MARK: - Button Bar
- @ViewBuilder
private var buttonBar: some View {
HStack(spacing: 16) {
CustomButton(title: t("contacts__import_select"), variant: .secondary) {
diff --git a/Bitkit/Views/Contacts/ContactImportSelectView.swift b/Bitkit/Views/Contacts/ContactImportSelectView.swift
index d9c34f55..4051d44e 100644
--- a/Bitkit/Views/Contacts/ContactImportSelectView.swift
+++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift
@@ -86,7 +86,6 @@ struct ContactImportSelectView: View {
// MARK: - Checkmark
- @ViewBuilder
private func checkmark(isSelected: Bool) -> some View {
ZStack {
if isSelected {
@@ -109,20 +108,12 @@ struct ContactImportSelectView: View {
// MARK: - Contact Avatar
- @ViewBuilder
private func contactAvatar(name: String, imageUrl: String?) -> some View {
Group {
if let imageUrl {
PubkyImage(uri: imageUrl, size: 48)
} else {
- Circle()
- .fill(Color.white.opacity(0.1))
- .frame(width: 48, height: 48)
- .overlay {
- Text(String(name.prefix(1)).uppercased())
- .font(Fonts.bold(size: 17))
- .foregroundColor(.textPrimary)
- }
+ ContactAvatarLetter(source: name, size: 48)
}
}
.accessibilityHidden(true)
@@ -130,7 +121,6 @@ struct ContactImportSelectView: View {
// MARK: - Footer Bar
- @ViewBuilder
private var footerBar: some View {
VStack(spacing: 0) {
CustomDivider()
@@ -167,12 +157,9 @@ struct ContactImportSelectView: View {
// MARK: - Pill Button
- @ViewBuilder
private func pillButton(title: String, isActive: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
- Text(title)
- .font(Fonts.medium(size: 13))
- .foregroundColor(isActive ? .white64 : .textPrimary)
+ CaptionText(title, textColor: isActive ? .white64 : .textPrimary)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(
diff --git a/Bitkit/Views/Contacts/ContactsIntroView.swift b/Bitkit/Views/Contacts/ContactsIntroView.swift
index 31bb769a..37b811cd 100644
--- a/Bitkit/Views/Contacts/ContactsIntroView.swift
+++ b/Bitkit/Views/Contacts/ContactsIntroView.swift
@@ -4,6 +4,7 @@ struct ContactsIntroView: View {
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var navigation: NavigationViewModel
@EnvironmentObject var pubkyProfile: PubkyProfileManager
+ @EnvironmentObject var contactsManager: ContactsManager
var body: some View {
OnboardingView(
@@ -11,10 +12,11 @@ struct ContactsIntroView: View {
title: t("contacts__intro_title"),
description: t("contacts__intro_description"),
imageName: "group",
- buttonText: t("common__continue"),
+ buttonText: t("contacts__intro_add_contact"),
onButtonPress: {
app.hasSeenContactsIntro = true
if pubkyProfile.isAuthenticated {
+ contactsManager.shouldOpenAddContactSheet = true
navigation.navigate(.contacts)
} else if app.hasSeenProfileIntro {
navigation.navigate(.pubkyChoice)
@@ -36,6 +38,7 @@ struct ContactsIntroView: View {
.environmentObject(AppViewModel())
.environmentObject(NavigationViewModel())
.environmentObject(PubkyProfileManager())
+ .environmentObject(ContactsManager())
.preferredColorScheme(.dark)
}
}
diff --git a/Bitkit/Views/Contacts/ContactsListView.swift b/Bitkit/Views/Contacts/ContactsListView.swift
index 8a703f31..b28a4911 100644
--- a/Bitkit/Views/Contacts/ContactsListView.swift
+++ b/Bitkit/Views/Contacts/ContactsListView.swift
@@ -53,6 +53,7 @@ struct ContactsListView: View {
.sheet(isPresented: $showAddContactSheet) {
AddContactSheet(
currentPublicKey: pubkyProfile.publicKey,
+ contacts: contactsManager.contacts,
onAdd: { pubky in
navigation.navigate(.addContact(publicKey: pubky))
},
@@ -61,11 +62,15 @@ struct ContactsListView: View {
}
)
}
+ .onChange(of: contactsManager.shouldOpenAddContactSheet, initial: true) { _, shouldOpen in
+ guard shouldOpen else { return }
+ showAddContactSheet = true
+ contactsManager.shouldOpenAddContactSheet = false
+ }
}
// MARK: - Search Bar + Add Button
- @ViewBuilder
private var searchAndAddBar: some View {
HStack(spacing: 12) {
HStack(spacing: 12) {
@@ -118,7 +123,6 @@ struct ContactsListView: View {
// MARK: - My Profile Section
- @ViewBuilder
private func myProfileSection(_ profile: PubkyProfile) -> some View {
VStack(alignment: .leading, spacing: 0) {
sectionHeader(t("contacts__my_profile"))
@@ -163,7 +167,6 @@ struct ContactsListView: View {
// MARK: - Section Header
- @ViewBuilder
private func sectionHeader(_ title: String) -> some View {
CaptionMText(title, textColor: .white64)
.padding(.vertical, 16)
@@ -171,7 +174,6 @@ struct ContactsListView: View {
// MARK: - Contact Row
- @ViewBuilder
private func contactRow(name: String, truncatedKey: String, imageUrl: String?, onTap: @escaping () -> Void) -> some View {
Button(action: onTap) {
HStack(spacing: 16) {
@@ -191,20 +193,12 @@ struct ContactsListView: View {
.accessibilityLabel(name)
}
- @ViewBuilder
private func contactAvatar(name: String, imageUrl: String?) -> some View {
Group {
if let imageUrl {
PubkyImage(uri: imageUrl, size: 48)
} else {
- Circle()
- .fill(Color.white.opacity(0.1))
- .frame(width: 48, height: 48)
- .overlay {
- Text(String(name.prefix(1)).uppercased())
- .font(Fonts.bold(size: 17))
- .foregroundColor(.textPrimary)
- }
+ ContactAvatarLetter(source: name, size: 48)
}
}
.accessibilityHidden(true)
@@ -225,7 +219,6 @@ struct ContactsListView: View {
// MARK: - Loading & Empty States
- @ViewBuilder
private var loadingContent: some View {
VStack {
Spacer()
@@ -235,7 +228,6 @@ struct ContactsListView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
- @ViewBuilder
private func errorContent(message: String) -> some View {
VStack(spacing: 16) {
Spacer()
@@ -260,7 +252,6 @@ struct ContactsListView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
- @ViewBuilder
private var emptyContent: some View {
VStack(spacing: 0) {
if pubkyProfile.isAuthenticated, let profile = pubkyProfile.profile {
diff --git a/Bitkit/Views/Gift/GiftLoading.swift b/Bitkit/Views/Gift/GiftLoading.swift
index f8127444..b903db4c 100644
--- a/Bitkit/Views/Gift/GiftLoading.swift
+++ b/Bitkit/Views/Gift/GiftLoading.swift
@@ -110,6 +110,7 @@ struct GiftLoading: View {
message: code,
timestamp: nowTimestamp,
preimage: nil,
+ contact: nil,
createdAt: nowTimestamp,
updatedAt: nil,
seenAt: nil
diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift
index 9eeb03cf..7e0889ec 100644
--- a/Bitkit/Views/Profile/PayContactsView.swift
+++ b/Bitkit/Views/Profile/PayContactsView.swift
@@ -1,9 +1,15 @@
import SwiftUI
struct PayContactsView: View {
+ @AppStorage("hasConfirmedPublicPaykitEndpoints") private var hasConfirmedPublicPaykitEndpoints = false
+ @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false
+
+ @EnvironmentObject var app: AppViewModel
@EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var wallet: WalletViewModel
@State private var enablePayments = true
+ @State private var isSaving = false
var body: some View {
VStack(spacing: 0) {
@@ -39,11 +45,12 @@ struct PayContactsView: View {
BodyMText(t("profile__pay_contacts_toggle"), textColor: .white)
}
.tint(.pubkyGreen)
+ .disabled(isSaving)
.accessibilityIdentifier("PayContactsToggle")
.padding(.horizontal, 32)
- CustomButton(title: t("common__continue")) {
- navigation.path = [.profile]
+ CustomButton(title: t("common__continue"), isLoading: isSaving) {
+ await continueFlow()
}
.accessibilityIdentifier("PayContactsContinue")
.padding(.top, 16)
@@ -53,13 +60,39 @@ struct PayContactsView: View {
.bottomSafeAreaPadding()
.background(Color.customBlack)
.navigationBarHidden(true)
+ .task {
+ enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true
+ }
+ }
+
+ private func continueFlow() async {
+ let publish = enablePayments
+ isSaving = true
+ defer { isSaving = false }
+
+ do {
+ try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish)
+ sharesPublicPaykitEndpoints = publish
+ hasConfirmedPublicPaykitEndpoints = true
+ navigation.path = [.profile]
+ } catch {
+ enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true
+ Logger.error("Failed to sync public payment endpoints: \(error)", context: "PayContactsView")
+ app.toast(
+ type: .error,
+ title: t("common__error"),
+ description: error.localizedDescription
+ )
+ }
}
}
#Preview {
NavigationStack {
PayContactsView()
+ .environmentObject(AppViewModel())
.environmentObject(NavigationViewModel())
+ .environmentObject(WalletViewModel())
}
.preferredColorScheme(.dark)
}
diff --git a/Bitkit/Views/Scanner/ScannerScreen.swift b/Bitkit/Views/Scanner/ScannerScreen.swift
index c79684ac..9e27dffd 100644
--- a/Bitkit/Views/Scanner/ScannerScreen.swift
+++ b/Bitkit/Views/Scanner/ScannerScreen.swift
@@ -17,6 +17,10 @@ struct ScannerScreen: View {
return .electrum
}
+ if navigation.path.dropLast().last == .contacts {
+ return .addContact
+ }
+
return .main
}
diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift
index 21341cd2..b208e29b 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift
@@ -283,6 +283,7 @@ struct ActivityExplorer_Previews: PreviewProvider {
message: "Test payment",
timestamp: UInt64(Date().timeIntervalSince1970),
preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -310,6 +311,7 @@ struct ActivityExplorer_Previews: PreviewProvider {
confirmTimestamp: nil,
channelId: nil,
transferTxId: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift
index 8053984a..8bc37e36 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift
@@ -576,6 +576,7 @@ struct ActivityItemView_Previews: PreviewProvider {
message: "Splitting the lunch bill. Thanks for suggesting that amazing restaurant!",
timestamp: UInt64(Date().timeIntervalSince1970),
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -605,6 +606,7 @@ struct ActivityItemView_Previews: PreviewProvider {
confirmTimestamp: nil,
channelId: nil,
transferTxId: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift
index 09590f63..88f606fc 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift
@@ -4,6 +4,7 @@ import SwiftUI
struct ActivityLatest: View {
@EnvironmentObject private var activity: ActivityListViewModel
@EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var contactsManager: ContactsManager
@EnvironmentObject private var feeEstimatesManager: FeeEstimatesManager
@EnvironmentObject private var navigation: NavigationViewModel
@EnvironmentObject private var settings: SettingsViewModel
@@ -36,7 +37,7 @@ struct ActivityLatest: View {
/// Three or four vertical slots (by screen size) shared by: transfer banner, widgets onboarding
/// and activity items; only the item count shrinks so the total stays within the cap.
private var maxActivityItemsOnHome: Int {
- let slotCapacity = UIScreen.main.isSmall ? 3 : 4
+ let slotCapacity = UIScreen.main.isSmall ? ActivityDisplayConstants.maxHomeActivityItems - 1 : ActivityDisplayConstants.maxHomeActivityItems
var nonItemSlots = 0
if shouldShowBanner { nonItemSlots += 1 }
if settings.showWidgets, !app.hasDismissedWidgetsOnboardingHint { nonItemSlots += 1 }
@@ -60,7 +61,11 @@ struct ActivityLatest: View {
LazyVStack(alignment: .leading, spacing: 16) {
ForEach(Array(zip(rows.indices, rows)), id: \.1) { index, item in
NavigationLink(value: Route.activityDetail(item)) {
- ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates)
+ ActivityRow(
+ item: item,
+ feeEstimates: feeEstimatesManager.estimates,
+ contact: item.contact(in: contactsManager.contacts)
+ )
}
.accessibilityIdentifier("ActivityShort-\(index)")
}
diff --git a/Bitkit/Views/Wallets/Activity/ActivityRow.swift b/Bitkit/Views/Wallets/Activity/ActivityRow.swift
index a9b1a3a2..5cd91bcc 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityRow.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityRow.swift
@@ -4,14 +4,60 @@ import SwiftUI
struct ActivityRow: View {
let item: Activity
let feeEstimates: FeeRates?
+ let contact: PubkyContact?
+ let titleOverride: String?
+
+ init(item: Activity, feeEstimates: FeeRates?, contact: PubkyContact? = nil, titleOverride: String? = nil) {
+ self.item = item
+ self.feeEstimates = feeEstimates
+ self.contact = contact
+ self.titleOverride = titleOverride
+ }
+
+ private var rowTitleOverride: String? {
+ if let titleOverride {
+ return titleOverride
+ }
+
+ return contactTitle
+ }
+
+ private var contactTitle: String? {
+ guard let contact else { return nil }
+
+ let txType: PaymentType
+ switch item {
+ case let .lightning(lightning):
+ guard lightning.status == .succeeded else {
+ return nil
+ }
+ txType = lightning.txType
+
+ case let .onchain(onchain):
+ guard onchain.doesExist,
+ !onchain.isTransfer,
+ !(onchain.isBoosted && !onchain.confirmed)
+ else {
+ return nil
+ }
+ txType = onchain.txType
+ }
+
+ switch txType {
+ case .sent:
+ return t("contacts__activity_sent_to", variables: ["name": contact.displayName])
+ case .received:
+ return t("contacts__activity_received_from", variables: ["name": contact.displayName])
+ }
+ }
var body: some View {
Group {
switch item {
case let .lightning(activity):
- ActivityRowLightning(item: activity)
+ ActivityRowLightning(item: activity, titleOverride: rowTitleOverride)
case let .onchain(activity):
- ActivityRowOnchain(item: activity, feeEstimates: feeEstimates)
+ ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, titleOverride: rowTitleOverride)
}
}
.padding(16)
diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift
index ba354cbb..6b338291 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift
@@ -3,6 +3,12 @@ import SwiftUI
struct ActivityRowLightning: View {
let item: LightningActivity
+ let titleOverride: String?
+
+ init(item: LightningActivity, titleOverride: String? = nil) {
+ self.item = item
+ self.titleOverride = titleOverride
+ }
private var amountPrefix: String {
return item.txType == .sent ? "-" : "+"
@@ -21,6 +27,10 @@ struct ActivityRowLightning: View {
}
private var status: String {
+ if let titleOverride {
+ return titleOverride
+ }
+
switch item.status {
case .failed:
return t("wallet__activity_failed")
diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift
index aa975570..0149a444 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift
@@ -4,6 +4,13 @@ import SwiftUI
struct ActivityRowOnchain: View {
let item: OnchainActivity
let feeEstimates: FeeRates?
+ let titleOverride: String?
+
+ init(item: OnchainActivity, feeEstimates: FeeRates?, titleOverride: String? = nil) {
+ self.item = item
+ self.feeEstimates = feeEstimates
+ self.titleOverride = titleOverride
+ }
@State private var isCpfpChild: Bool = false
@@ -24,6 +31,10 @@ struct ActivityRowOnchain: View {
}
private var status: String {
+ if let titleOverride {
+ return titleOverride
+ }
+
if item.isTransfer {
return item.confirmed ? t("wallet__activity_transfer") : t("wallet__activity_transferring")
}
diff --git a/Bitkit/Views/Wallets/Activity/AllActivityView.swift b/Bitkit/Views/Wallets/Activity/AllActivityView.swift
index 0939b097..64832e76 100644
--- a/Bitkit/Views/Wallets/Activity/AllActivityView.swift
+++ b/Bitkit/Views/Wallets/Activity/AllActivityView.swift
@@ -5,17 +5,16 @@ struct AllActivityView: View {
@EnvironmentObject private var app: AppViewModel
@EnvironmentObject private var wallet: WalletViewModel
+ @State private var headerContentHeight: CGFloat = 116
+
private var headerTopPadding: CGFloat {
- // NavBar + Filter + SegmentedControl + spacing
- return ScreenLayout.topPaddingWithoutSafeArea + 116
+ ScreenLayout.topPaddingWithoutSafeArea + headerContentHeight
}
var body: some View {
ZStack(alignment: .top) {
- // ScrollView - base layer, full height, content scrolls behind header
ScrollView(showsIndicators: false) {
ActivityList(viewType: .all)
- // .padding(.top, headerTopPadding)
.scrollDismissesKeyboard(.interactively)
.highPriorityGesture(
// TODO: rewrite using TabView
@@ -62,7 +61,6 @@ struct AllActivityView: View {
}
.transition(.move(edge: .leading).combined(with: .opacity))
- // Header - overlay on top, scroll content goes behind it
VStack(spacing: 0) {
NavigationBar(title: t("wallet__activity"))
.padding(.bottom, 16)
@@ -72,6 +70,14 @@ struct AllActivityView: View {
SegmentedControl(selectedTab: $activity.selectedTab, tabs: ActivityTab.allCases)
}
+ .background(
+ GeometryReader { proxy in
+ Color.clear.preference(key: ActivityHeaderHeightPreferenceKey.self, value: proxy.size.height)
+ }
+ )
+ .onPreferenceChange(ActivityHeaderHeightPreferenceKey.self) { height in
+ headerContentHeight = height
+ }
.frame(maxWidth: .infinity, alignment: .top)
.padding(.horizontal, 16)
.background(
@@ -112,6 +118,14 @@ struct AllActivityView: View {
}
}
+private struct ActivityHeaderHeightPreferenceKey: PreferenceKey {
+ static let defaultValue: CGFloat = 116
+
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value = nextValue()
+ }
+}
+
#Preview {
NavigationStack {
AllActivityView()
diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
index d4b62566..d6672b27 100644
--- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
+++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
@@ -197,6 +197,7 @@ struct LnurlPayConfirm: View {
let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11)
let paymentHash = String(describing: parsedInvoice.paymentHash())
+ let contactPublicKey = app.contactPaymentContext?.publicKey
do {
// Perform the Lightning payment (10s timeout → navigate to pending for hold invoices)
@@ -206,10 +207,11 @@ struct LnurlPayConfirm: View {
bolt11: bolt11,
sats: nil,
onTimeout: {
- app.addPendingPaymentHash(paymentHash)
+ app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey)
navigationPath.append(.pending(paymentHash: paymentHash))
}
)
+ app.addPendingContactPaymentContext(paymentHash, contactPublicKey: contactPublicKey)
Logger.info("LNURL payment successful: \(paymentHash)")
navigationPath.append(.success(paymentId: paymentHash))
} catch is PaymentTimeoutError {
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index bb24fec4..98f5a2c4 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct SendConfirmationView: View {
@EnvironmentObject var app: AppViewModel
+ @EnvironmentObject var activityList: ActivityListViewModel
@EnvironmentObject var currency: CurrencyViewModel
@EnvironmentObject var feeEstimatesManager: FeeEstimatesManager
@EnvironmentObject var settings: SettingsViewModel
@@ -447,6 +448,7 @@ struct SendConfirmationView: View {
private func performPayment() async throws {
var createdMetadataPaymentId: String? = nil
+ let contactPublicKey = app.contactPaymentContext?.publicKey
do {
if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice {
@@ -468,10 +470,11 @@ struct SendConfirmationView: View {
bolt11: invoice.bolt11,
sats: paymentSats,
onTimeout: {
- app.addPendingPaymentHash(paymentHash)
+ app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey)
navigationPath.append(.pending(paymentHash: paymentHash))
}
)
+ await syncContactForActivity(paymentId: paymentHash, contactPublicKey: contactPublicKey)
Logger.info("Lightning payment successful: \(paymentHash)")
navigationPath.append(.success(paymentId: paymentHash))
} catch is PaymentTimeoutError {
@@ -495,7 +498,8 @@ struct SendConfirmationView: View {
address: invoice.address,
amount: amount,
fee: UInt64(transactionFee),
- feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1
+ feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1,
+ contact: contactPublicKey
)
// Set the amount for the success screen
@@ -526,6 +530,20 @@ struct SendConfirmationView: View {
}
}
+ private func syncContactForActivity(paymentId: String, contactPublicKey: String?) async {
+ guard let contactPublicKey else {
+ return
+ }
+
+ do {
+ app.addPendingContactPaymentContext(paymentId, contactPublicKey: contactPublicKey)
+ try await activityList.setContact(contactPublicKey, forPaymentId: paymentId)
+ app.consumeContactPaymentContext(forPendingPaymentHash: paymentId)
+ } catch {
+ Logger.warn("Failed to set contact for activity \(paymentId): \(error)", context: "SendConfirmationView")
+ }
+ }
+
private func validatePayment() async -> [WarningType] {
var warnings: [WarningType] = []
diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift
index 5340d2a4..481b7191 100644
--- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift
+++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift
@@ -81,8 +81,12 @@ struct SendPendingScreen: View {
guard let resolution, resolution.paymentHash == paymentHash else { return }
app.consumeSendSheetPendingResolution(paymentHash: paymentHash)
if resolution.success {
- navigationPath.append(.success(paymentId: paymentHash))
+ Task { @MainActor in
+ await applyPendingContactContextIfNeeded()
+ navigationPath.append(.success(paymentId: paymentHash))
+ }
} else {
+ app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash)
navigationPath.append(.failure)
}
}
@@ -97,9 +101,24 @@ struct SendPendingScreen: View {
times: 12,
interval: 2
)
- foundActivity = activity
+ await applyPendingContactContextIfNeeded()
+ let updatedActivity = try? await activityList.findActivity(byPaymentId: paymentHash)
+ foundActivity = updatedActivity ?? activity
} catch {
Logger.warn("Could not find activity for pending payment \(paymentHash): \(error)")
}
}
+
+ private func applyPendingContactContextIfNeeded() async {
+ guard let contactPublicKey = app.contactPaymentContext(forPendingPaymentHash: paymentHash)?.publicKey else {
+ return
+ }
+
+ do {
+ try await activityList.setContact(contactPublicKey, forPaymentId: paymentHash)
+ app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash)
+ } catch {
+ Logger.warn("Failed to set pending contact for payment \(paymentHash): \(error)", context: "SendPendingScreen")
+ }
+ }
}
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index 3d3883ca..f09fab6f 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -108,6 +108,9 @@ struct SendSheet: View {
}
}
}
+ .onDisappear {
+ app.contactPaymentContext = nil
+ }
.onChange(of: wallet.nodeLifecycleState) { _, state in
// When the node becomes running and we have a scanned invoice, run deferred validation.
// This covers:
diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift
index cc430dc6..c2170f0f 100644
--- a/Bitkit/Views/Wallets/Send/SendSuccess.swift
+++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift
@@ -79,7 +79,9 @@ struct SendSuccess: View {
variant: .secondary,
isDisabled: foundActivity == nil
) {
- navigation.navigate(.activityDetail(foundActivity!))
+ if let foundActivity {
+ navigation.navigate(.activityDetail(foundActivity))
+ }
sheets.hideSheet()
}
.accessibilityIdentifier("Details")
@@ -111,9 +113,24 @@ struct SendSuccess: View {
interval: 5
)
- foundActivity = activity
+ await applyPendingContactContextIfNeeded()
+ let updatedActivity = try? await activityListViewModel.findActivity(byPaymentId: paymentId)
+ foundActivity = updatedActivity ?? activity
} catch {
Logger.warn("Could not find activity for payment ID: \(paymentId) after 12 attempts")
}
}
+
+ private func applyPendingContactContextIfNeeded() async {
+ guard let contactPublicKey = app.contactPaymentContext(forPendingPaymentHash: paymentId)?.publicKey else {
+ return
+ }
+
+ do {
+ try await activityListViewModel.setContact(contactPublicKey, forPaymentId: paymentId, syncLdkPayments: false)
+ app.consumeContactPaymentContext(forPendingPaymentHash: paymentId)
+ } catch {
+ Logger.warn("Failed to set pending contact for payment \(paymentId): \(error)", context: "SendSuccess")
+ }
+ }
}
diff --git a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift
index c47e6555..f514d47e 100644
--- a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift
+++ b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift
@@ -443,6 +443,7 @@ struct BoostSheet: View {
confirmTimestamp: nil,
channelId: nil,
transferTxId: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift
index dcf40ed9..989b68eb 100644
--- a/BitkitTests/ActivityListTest.swift
+++ b/BitkitTests/ActivityListTest.swift
@@ -42,6 +42,7 @@ final class ActivityTests: XCTestCase {
message: "Test payment",
timestamp: timestamp,
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -90,6 +91,7 @@ final class ActivityTests: XCTestCase {
confirmTimestamp: nil,
channelId: nil,
transferTxId: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -128,6 +130,7 @@ final class ActivityTests: XCTestCase {
message: "Test payment",
timestamp: timestamp,
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -166,6 +169,7 @@ final class ActivityTests: XCTestCase {
message: "Test payment 1",
timestamp: timestamp,
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -182,6 +186,7 @@ final class ActivityTests: XCTestCase {
message: "Test payment 2",
timestamp: timestamp,
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -224,6 +229,7 @@ final class ActivityTests: XCTestCase {
message: "Test payment 1",
timestamp: timestamp,
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -247,6 +253,7 @@ final class ActivityTests: XCTestCase {
confirmTimestamp: nil,
channelId: nil,
transferTxId: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -302,6 +309,7 @@ final class ActivityTests: XCTestCase {
message: "Test payment",
timestamp: timestamp,
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -322,6 +330,7 @@ final class ActivityTests: XCTestCase {
message: "Updated test payment",
timestamp: timestamp,
preimage: "preimage123",
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -359,6 +368,7 @@ final class ActivityTests: XCTestCase {
message: "Test payment",
timestamp: timestamp,
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -396,6 +406,7 @@ final class ActivityTests: XCTestCase {
message: "Test payment 1",
timestamp: timestamp,
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -419,6 +430,7 @@ final class ActivityTests: XCTestCase {
confirmTimestamp: nil,
channelId: nil,
transferTxId: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
@@ -435,6 +447,7 @@ final class ActivityTests: XCTestCase {
message: "Test payment 3",
timestamp: timestamp,
preimage: nil,
+ contact: nil,
createdAt: nil,
updatedAt: nil,
seenAt: nil
diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift
index e81c490e..a67909ad 100644
--- a/BitkitTests/ContactsManagerTests.swift
+++ b/BitkitTests/ContactsManagerTests.swift
@@ -1,4 +1,5 @@
@testable import Bitkit
+import BitkitCore
import XCTest
@MainActor
@@ -24,6 +25,61 @@ final class ContactsManagerTests: XCTestCase {
XCTAssertFalse(PubkyPublicKeyFormat.matches(prefixedKey, "pubkyinvalid"))
}
+ func testActivityContactResolvesLightningContactKey() {
+ let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let contact = makeContact(publicKey: "pubky\(rawKey)")
+ let activity = Activity.lightning(
+ LightningActivity(
+ id: "test-lightning-contact",
+ txType: .sent,
+ status: .succeeded,
+ value: 1000,
+ fee: 10,
+ invoice: "lnbc...",
+ message: "",
+ timestamp: 0,
+ preimage: nil,
+ contact: rawKey,
+ createdAt: nil,
+ updatedAt: nil,
+ seenAt: nil
+ )
+ )
+
+ XCTAssertEqual(activity.contact(in: [contact])?.publicKey, contact.publicKey)
+ }
+
+ func testActivityContactResolvesBoostingOnchainContactKey() {
+ let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let contact = makeContact(publicKey: "pubky\(rawKey)")
+ let activity = Activity.onchain(
+ OnchainActivity(
+ id: "test-onchain-boosting-contact",
+ txType: .sent,
+ txId: "txid",
+ value: 1000,
+ fee: 10,
+ feeRate: 1,
+ address: "bcrt1...",
+ confirmed: false,
+ timestamp: 0,
+ isBoosted: true,
+ boostTxIds: [],
+ isTransfer: false,
+ doesExist: true,
+ confirmTimestamp: nil,
+ channelId: nil,
+ transferTxId: nil,
+ contact: contact.publicKey,
+ createdAt: nil,
+ updatedAt: nil,
+ seenAt: nil
+ )
+ )
+
+ XCTAssertEqual(activity.contact(in: [contact])?.publicKey, contact.publicKey)
+ }
+
func testResolveAddContactValidationReturnsEmptyForBlankInput() {
XCTAssertEqual(resolveAddContactValidation(input: " ", ownPublicKey: nil), .empty)
}
@@ -45,6 +101,20 @@ final class ContactsManagerTests: XCTestCase {
)
}
+ func testResolveAddContactValidationReturnsExistingContactForDuplicate() {
+ let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let publicKey = "pubky\(rawKey)"
+
+ XCTAssertEqual(
+ resolveAddContactValidation(
+ input: rawKey,
+ ownPublicKey: nil,
+ existingContacts: [makeContact(publicKey: publicKey)]
+ ),
+ .existingContact
+ )
+ }
+
func testResolveAddContactValidationReturnsNormalizedKeyForValidInput() {
let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
@@ -62,6 +132,7 @@ final class ContactsManagerTests: XCTestCase {
manager.contacts = [contact]
manager.hasLoaded = true
manager.loadErrorMessage = "still here"
+ manager.shouldOpenAddContactSheet = true
manager.pendingImportProfile = profile
manager.pendingImportContacts = [contact]
@@ -70,6 +141,7 @@ final class ContactsManagerTests: XCTestCase {
XCTAssertEqual(manager.contacts, [contact])
XCTAssertTrue(manager.hasLoaded)
XCTAssertEqual(manager.loadErrorMessage, "still here")
+ XCTAssertTrue(manager.shouldOpenAddContactSheet)
XCTAssertNil(manager.pendingImportProfile)
XCTAssertTrue(manager.pendingImportContacts.isEmpty)
XCTAssertFalse(manager.hasPendingImport)
@@ -201,8 +273,8 @@ final class ContactsManagerTests: XCTestCase {
)
}
- private func makeProfile(publicKey: String) -> PubkyProfile {
- PubkyProfile(
+ private func makeProfile(publicKey: String) -> Bitkit.PubkyProfile {
+ Bitkit.PubkyProfile(
publicKey: publicKey,
name: "Alice",
bio: "bio",
@@ -213,7 +285,7 @@ final class ContactsManagerTests: XCTestCase {
)
}
- private func makeContact(publicKey: String) -> PubkyContact {
- PubkyContact(publicKey: publicKey, profile: makeProfile(publicKey: publicKey))
+ private func makeContact(publicKey: String) -> Bitkit.PubkyContact {
+ Bitkit.PubkyContact(publicKey: publicKey, profile: makeProfile(publicKey: publicKey))
}
}
diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift
new file mode 100644
index 00000000..f4593ae2
--- /dev/null
+++ b/BitkitTests/PublicPaykitServiceTests.swift
@@ -0,0 +1,227 @@
+@testable import Bitkit
+import Foundation
+import LDKNode
+import XCTest
+
+final class PublicPaykitServiceTests: XCTestCase {
+ func testParseEndpointReadsSpecPayloadObject() {
+ let endpoint = PublicPaykitService.parseEndpoint(
+ methodId: "btc-lightning-bolt11",
+ endpointData: #"{"value":"lnbc1example","min":"1000","max":"2000"}"#
+ )
+
+ XCTAssertEqual(endpoint?.methodId, .bitcoinLightningBolt11)
+ XCTAssertEqual(endpoint?.value, "lnbc1example")
+ XCTAssertEqual(endpoint?.min, "1000")
+ XCTAssertEqual(endpoint?.max, "2000")
+ }
+
+ func testParseEndpointRejectsRawStringPayload() {
+ let endpoint = PublicPaykitService.parseEndpoint(
+ methodId: "btc-bitcoin-p2wpkh",
+ endpointData: "bc1qexampleaddress"
+ )
+
+ XCTAssertNil(endpoint)
+ }
+
+ func testParseEndpointRejectsUnsupportedMethodId() {
+ let endpoint = PublicPaykitService.parseEndpoint(
+ methodId: "btc-lightning-bolt12",
+ endpointData: #"{"value":"anything"}"#
+ )
+
+ XCTAssertNil(endpoint)
+ }
+
+ func testParseEndpointReadsPaykyLnurlMethodId() {
+ let endpoint = PublicPaykitService.parseEndpoint(
+ methodId: "btc-lightning-lnurl",
+ endpointData: #"{"value":"lnurl1example"}"#
+ )
+
+ XCTAssertEqual(endpoint?.methodId, .bitcoinLightningLnurl)
+ XCTAssertEqual(endpoint?.value, "lnurl1example")
+ }
+
+ func testParseEndpointReadsNetworkSpecificOnchainMethodIds() {
+ XCTAssertEqual(
+ PublicPaykitService.parseEndpoint(
+ methodId: "btc-testnet-p2wpkh",
+ endpointData: #"{"value":"tb1qexample"}"#
+ )?.methodId,
+ .testnetOnchainP2wpkh
+ )
+ XCTAssertEqual(
+ PublicPaykitService.parseEndpoint(
+ methodId: "btc-regtest-p2tr",
+ endpointData: #"{"value":"bcrt1pexample"}"#
+ )?.methodId,
+ .regtestOnchainP2tr
+ )
+ }
+
+ func testParseEndpointRejectsNonSpecLegacyLnurlMethodId() {
+ let endpoint = PublicPaykitService.parseEndpoint(
+ methodId: "btc-lightning-lnurl-pay",
+ endpointData: #"{"value":"lnurl1example"}"#
+ )
+
+ XCTAssertNil(endpoint)
+ }
+
+ func testKnownMethodIdsFollowPaymentEndpointIdentifierSpec() {
+ let specPattern = #"^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$"#
+
+ for methodId in PublicPaykitService.MethodId.allCases {
+ XCTAssertNotNil(methodId.rawValue.range(of: specPattern, options: .regularExpression), "\(methodId.rawValue) must be asset-rail-endpoint")
+ }
+ }
+
+ func testSerializePayloadWrapsValueInJsonObject() throws {
+ let payload = try PublicPaykitService.serializePayload(value: " lnbc1invoice ")
+ let json = try XCTUnwrap(payload.data(using: .utf8))
+ let object = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: String])
+
+ XCTAssertEqual(object["value"], "lnbc1invoice")
+ XCTAssertEqual(object.count, 1)
+ }
+
+ func testPaymentRequestCombinesOnchainAndBolt11Endpoints() {
+ let request = PublicPaykitService.paymentRequest(from: [
+ endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"),
+ endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress"),
+ ])
+
+ XCTAssertEqual(request, "bitcoin:bc1qaddress?lightning=lnbc1invoice")
+ }
+
+ func testPaymentRequestPercentEncodesLightningParameter() {
+ let request = PublicPaykitService.paymentRequest(from: [
+ endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice?amount=1&label=test"),
+ endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress"),
+ ])
+
+ XCTAssertEqual(request, "bitcoin:bc1qaddress?lightning=lnbc1invoice%3Famount%3D1%26label%3Dtest")
+ }
+
+ func testPaymentRequestPrefersTaprootWhenMultipleOnchainEndpointsExist() {
+ let request = PublicPaykitService.paymentRequest(from: [
+ endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"),
+ endpoint(.bitcoinOnchainP2pkh, value: "1legacy"),
+ endpoint(.bitcoinOnchainP2wpkh, value: "bc1qsegwit"),
+ endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot"),
+ ])
+
+ XCTAssertEqual(request, "bitcoin:bc1ptaproot?lightning=lnbc1invoice")
+ }
+
+ func testPaymentRequestFallsBackToPreferredEndpointWhenCombinedRequestIsNotAvailable() {
+ let request = PublicPaykitService.paymentRequest(from: [
+ endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"),
+ ])
+
+ XCTAssertEqual(request, "lnbc1invoice")
+ }
+
+ func testPaymentRequestFallsBackToLnurlOnlyEndpoint() {
+ let request = PublicPaykitService.paymentRequest(from: [
+ endpoint(.bitcoinLightningLnurl, value: "lnurl1example"),
+ ])
+
+ XCTAssertEqual(request, "lnurl1example")
+ }
+
+ func testOnchainMethodIdUsesAddressPrefixAndNetwork() {
+ XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bc1pexample", network: .bitcoin), .bitcoinOnchainP2tr)
+ XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "tb1qexample", network: .testnet), .testnetOnchainP2wpkh)
+ XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bcrt1qexample", network: .regtest), .regtestOnchainP2wpkh)
+ XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "3Example", network: .bitcoin), .bitcoinOnchainP2sh)
+ XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "2Example", network: .regtest), .regtestOnchainP2sh)
+ XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "1Example", network: .bitcoin), .bitcoinOnchainP2pkh)
+ }
+
+ func testPaymentLaunchResultFailureMessageKeys() {
+ XCTAssertNil(PublicPaykitPaymentLaunchResult.opened(paymentRequest: "bitcoin:bcrt1ptest").contactPaymentFailureMessageKey)
+ XCTAssertEqual(PublicPaykitPaymentLaunchResult.noEndpoint.contactPaymentFailureMessageKey, "slashtags__error_pay_empty_msg")
+ XCTAssertEqual(PublicPaykitPaymentLaunchResult.notOpened.contactPaymentFailureMessageKey, "slashtags__error_pay_not_opened_msg")
+ }
+
+ func testPayableEndpointsFiltersInvalidDecodedEndpoints() async {
+ let payable = await PublicPaykitService.payableEndpoints(from: [
+ endpoint(.bitcoinLightningBolt11, value: "not-a-bolt11"),
+ endpoint(.bitcoinOnchainP2tr, value: "not-an-address"),
+ ])
+
+ XCTAssertTrue(payable.isEmpty)
+ }
+
+ func testMethodIdsToRemoveWhenUnpublishingOnlyIncludesBitkitManagedEndpoints() {
+ let methodIds = PublicPaykitService.methodIdsToRemoveWhenUnpublishing(existingMethodIds: [
+ .bitcoinLightningBolt11,
+ .bitcoinLightningLnurl,
+ .bitcoinOnchainP2tr,
+ ])
+
+ XCTAssertEqual(methodIds, [.bitcoinLightningBolt11, .bitcoinOnchainP2tr])
+ }
+
+ func testPublishedEndpointSyncPlanRemovesStalePublishedMethods() {
+ let desired = [
+ endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"),
+ endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot"),
+ ]
+
+ let plan = PublicPaykitService.publishedEndpointSyncPlan(
+ existingEndpoints: [
+ .bitcoinLightningBolt11: #"{"value":"oldinvoice"}"#,
+ .bitcoinOnchainP2wpkh: #"{"value":"bc1qsegwit"}"#,
+ .bitcoinOnchainP2sh: #"{"value":"3nested"}"#,
+ ],
+ desiredEndpoints: desired
+ )
+
+ XCTAssertEqual(plan.endpointsToSet, desired)
+ XCTAssertEqual(plan.methodIdsToRemove, [.bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh])
+ }
+
+ func testPublishedEndpointSyncPlanSkipsUnchangedPublishedPayloads() {
+ let bolt11 = endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice")
+ let taproot = endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot")
+
+ let plan = PublicPaykitService.publishedEndpointSyncPlan(
+ existingEndpoints: [
+ .bitcoinLightningBolt11: bolt11.rawPayload,
+ .bitcoinOnchainP2tr: #"{"value":"oldtaproot"}"#,
+ ],
+ desiredEndpoints: [bolt11, taproot]
+ )
+
+ XCTAssertEqual(plan.endpointsToSet, [taproot])
+ XCTAssertTrue(plan.methodIdsToRemove.isEmpty)
+ }
+
+ func testPublishedEndpointSyncPlanPreservesExternallyOwnedLnurlEndpoint() {
+ let bolt11 = endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice")
+
+ let plan = PublicPaykitService.publishedEndpointSyncPlan(
+ existingEndpoints: [
+ .bitcoinLightningLnurl: #"{"value":"lnurl1external"}"#,
+ ],
+ desiredEndpoints: [bolt11]
+ )
+
+ XCTAssertEqual(plan.endpointsToSet, [bolt11])
+ XCTAssertTrue(plan.methodIdsToRemove.isEmpty)
+ }
+
+ private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint {
+ PublicPaykitService.Endpoint(
+ methodId: methodId,
+ value: value,
+ min: nil,
+ max: nil,
+ rawPayload: #"{"value":"\#(value)"}"#
+ )
+ }
+}
diff --git a/BitkitTests/ZipServiceTests.swift b/BitkitTests/ZipServiceTests.swift
index 59af86dd..573cfeb6 100644
--- a/BitkitTests/ZipServiceTests.swift
+++ b/BitkitTests/ZipServiceTests.swift
@@ -48,6 +48,20 @@ final class ZipServiceTests: XCTestCase {
}
}
+ func testCreateZipRejectsDotFilenames() throws {
+ let zipService = ZipService()
+
+ for filename in [".", "..", "logs/.."] {
+ let filesToZip: [FileToZip] = [.data(Data([0x01]), filename: filename)]
+
+ XCTAssertThrowsError(try zipService.getZipData(zipFilename: "invalid", filesToZip: filesToZip)) { error in
+ guard case CreateZipError.invalidFilename = error else {
+ return XCTFail("Expected invalidFilename error for \(filename), got \(error)")
+ }
+ }
+ }
+ }
+
func testCreateZipOverwritesExistingFileByDefault() throws {
let sourceDirectory = testRootDirectoryURL.appendingPathComponent("input")
try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true)
diff --git a/changelog.d/next/531.added.md b/changelog.d/next/531.added.md
new file mode 100644
index 00000000..6cfb4f23
--- /dev/null
+++ b/changelog.d/next/531.added.md
@@ -0,0 +1 @@
+Support publishing public Paykit endpoints and paying pubky contacts through public payment endpoints.