Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ buildServer.json

# AIs
.ai/
.codex/
.claude/*.local*
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = exactVersion;
version = "0.1.0-rc5";
version = "0.1.0-rc6";
};
};
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
Expand Down

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

15 changes: 15 additions & 0 deletions Bitkit/Assets.xcassets/icons/user-minus.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "user-minus.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions Bitkit/Components/PubkyContactAvatar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import SwiftUI

struct PubkyContactAvatar: View {
let name: String
let imageUrl: String?
let size: CGFloat

init(name: String, imageUrl: String?, size: CGFloat) {
self.name = name
self.imageUrl = imageUrl
self.size = size
}

init(contact: PubkyContact, size: CGFloat) {
name = contact.displayName
imageUrl = contact.profile.imageUrl
self.size = size
}

var body: some View {
Group {
if let imageUrl {
PubkyImage(uri: imageUrl, size: size)
} else {
ContactAvatarLetter(source: name, size: size)
}
}
.accessibilityHidden(true)
}
}
42 changes: 42 additions & 0 deletions Bitkit/Components/PubkyContactRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import SwiftUI

struct PubkyContactRow: View {
let contact: PubkyContact
var verticalPadding: CGFloat = 12
var showsDivider = true
var isLoading = false
let action: () -> Void

var body: some View {
VStack(spacing: 0) {
Button(action: action) {
HStack(spacing: 16) {
PubkyContactAvatar(contact: contact, size: 48)

VStack(alignment: .leading, spacing: 4) {
CaptionText(contact.profile.truncatedPublicKey.localizedUppercase)
.lineLimit(1)

BodyMSBText(contact.displayName)
.lineLimit(1)
}

Spacer()

if isLoading {
ProgressView()
}
}
.padding(.vertical, verticalPadding)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(isLoading)
.accessibilityLabel(contact.displayName)

if showsDivider {
CustomDivider()
}
}
}
}
2 changes: 2 additions & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ struct MainNavView: View {
case .contactsIntro: ContactsIntroView()
case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey)
case let .contactActivity(publicKey): ContactActivityView(publicKey: publicKey)
case let .assignActivityContact(activityId): AssignActivityContactView(activityId: activityId)
case .contactImportOverview:
if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) {
missingPendingImportView(fallbackRoute: fallbackRoute)
Expand Down Expand Up @@ -450,6 +451,7 @@ struct MainNavView: View {
case .widgetsSettings: WidgetsSettingsScreen()
case .notifications: NotificationsSettings()
case .notificationsIntro: NotificationsIntro()
case .paymentPreference: PaymentPreferenceView()

// Security settings
case .changePin: ChangePinScreen()
Expand Down
3 changes: 2 additions & 1 deletion Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,8 @@ class PubkyProfileManager: ObservableObject {
}

private static func clearPublicPaykitSharingState() {
UserDefaults.standard.set(false, forKey: "sharesPublicPaykitEndpoints")
UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey)
UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey)
UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints")
PrivatePaykitService.setContactSharingCleanupPending(false)
UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11")
Expand Down
12 changes: 11 additions & 1 deletion Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,7 @@
"settings__adv__pp_header" = "Choose how you prefer to receive money when users send funds to your profile key.";
"settings__adv__pp_footer" = "* This requires sharing payment data.";
"settings__adv__pp_drag" = "Payment preference (drag to reorder)";
"settings__adv__pp_contacts" = "Pay to/from contacts";
"settings__adv__pp_contacts" = "Payments from contacts";
"settings__adv__pp_contacts_switch" = "Enable payments with contacts*";
"settings__adv__address_viewer" = "Address Viewer";
"settings__adv__sweep_funds" = "Sweep Funds";
Expand Down Expand Up @@ -1416,3 +1416,13 @@
"widgets__weather__current_fee" = "Current average fee";
"widgets__weather__next_block" = "Next block inclusion";
"widgets__weather__error" = "Couldn\'t get current fee weather";

"settings__adv__pp_options" = "Payment options";
"settings__adv__pp_lightning" = "Lightning (Bitkit)";
"settings__adv__pp_onchain" = "On-chain (Bitkit)";
"settings__adv__pp_private_contacts" = "Private payments with contacts";
"settings__adv__pp_public_contacts" = "Public payments with contacts*";
"settings__adv__pp_public_footer" = "*Public payments with contacts requires payment data to be shared publicly.";
"settings__adv__pp_both" = "Both";
"settings__adv__pp_lightning_short" = "Lightning";
"settings__adv__pp_onchain_short" = "On-chain";
83 changes: 71 additions & 12 deletions Bitkit/Services/PrivatePaykitService+Contacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@ import Foundation
// MARK: - Saved Contacts

extension PrivatePaykitService {
func prepareSavedContacts(_ publicKeys: [String], wallet: WalletViewModel) async {
@discardableResult
func prepareSavedContacts(
_ publicKeys: [String],
wallet: WalletViewModel,
requireImmediatePublication: Bool = false
) async -> Error? {
let publicKeys = rememberSavedContacts(publicKeys, replacing: true)
guard await canPublishPrivateEndpoints(wallet: wallet) else { return }
guard await canPublishPrivateEndpoints(wallet: wallet) else { return nil }
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
await publishLocalEndpoints(for: publicKeys, wallet: wallet, maxAdvanceSteps: 3, reason: "prepare")
return await publishLocalEndpoints(
for: publicKeys,
wallet: wallet,
maxAdvanceSteps: 3,
reason: "prepare",
requireImmediatePublication: requireImmediatePublication
)
}

func refreshSavedContactEndpoints(for publicKeys: [String], wallet: WalletViewModel) async {
Expand All @@ -29,8 +40,11 @@ extension PrivatePaykitService {
}

func removePublishedEndpoints() async throws {
try await removePublishedEndpoints(for: Array(state.contacts.keys))
}

func removePublishedEndpoints(for publicKeys: [String]) async throws {
invalidateLinkEstablishmentWork()
let publicKeys = Array(state.contacts.keys)
var firstError: Error?

for publicKey in publicKeys {
Expand Down Expand Up @@ -97,15 +111,30 @@ extension PrivatePaykitService {
guard UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) else { return }

do {
try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false)
try await removePublishedEndpoints()
if !UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) {
try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false)
}

let publicKeys = pendingPrivateEndpointRemovalKeys(savedPublicKeys: savedPublicKeys)
if !publicKeys.isEmpty {
try await removePublishedEndpoints(for: publicKeys)
}
await clearUnsavedContactState(savedPublicKeys: savedPublicKeys)
Self.setContactSharingCleanupPending(false)
} catch {
Logger.warn("Failed to retry pending Paykit contact endpoint removal: \(error)", context: "PrivatePaykit")
}
}

func pendingPrivateEndpointRemovalKeys(savedPublicKeys publicKeys: [String]) -> [String] {
if !UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) {
return Array(state.contacts.keys)
}

let savedKeys = Set(normalizedSavedContactKeys(publicKeys))
return state.contacts.keys.filter { !savedKeys.contains($0) }
}

func clearUnsavedContactState(savedPublicKeys publicKeys: [String]) async {
let savedKeys = Set(normalizedSavedContactKeys(publicKeys))
let staleKeys = state.contacts.keys.filter { !savedKeys.contains($0) }
Expand All @@ -117,26 +146,35 @@ extension PrivatePaykitService {
await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(excludingPublicKeys: Array(savedKeys))
}

@discardableResult
func publishLocalEndpoints(
for publicKeys: [String],
wallet: WalletViewModel,
maxAdvanceSteps: Int,
reason: String,
scheduleRetries: Bool = true,
forceLocalPublishWhenRemoteEmpty: Bool = false,
forceRefreshLightning: Bool = false
) async {
forceRefreshLightning: Bool = false,
requireImmediatePublication: Bool = false
) async -> Error? {
let generation = stateGeneration
var firstError: Error?

for publicKey in publicKeys {
var retryPublicKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey
do {
guard let normalizedKey = knownSavedContact(publicKey) else {
continue
}
retryPublicKey = normalizedKey

guard let linkId = try await establishedLinkId(for: normalizedKey, maxAdvanceSteps: maxAdvanceSteps, generation: generation) else {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
}
if requireImmediatePublication, shouldRequirePrivateEndpointRemoval(publicKey: normalizedKey), firstError == nil {
firstError = PrivatePaykitError.privateUnavailable
}
continue
}

Expand All @@ -161,6 +199,9 @@ extension PrivatePaykitService {
do {
fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation)
} catch {
if requireImmediatePublication, firstError == nil {
firstError = error
}
if shouldCountAsStaleLinkFailure(error) {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
Expand Down Expand Up @@ -188,6 +229,8 @@ extension PrivatePaykitService {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
}
} else if scheduleRetries, await shouldRetryMissingPrivateLightningEndpoint(for: normalizedKey, wallet: wallet) {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
} else {
cancelPendingPublicationRetry(for: normalizedKey)
}
Expand All @@ -198,6 +241,9 @@ extension PrivatePaykitService {
do {
fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation)
} catch {
if requireImmediatePublication, firstError == nil {
firstError = error
}
if shouldCountAsStaleLinkFailure(error) {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
Expand All @@ -214,7 +260,7 @@ extension PrivatePaykitService {
continue
}

// Recovery retries may need to send the same map again if restored Noise counters diverged.
// Recovery retries may need to resend the same map after a link is re-established and remote state is empty.
let shouldForcePublish = forceLocalPublishWhenRemoteEmpty &&
fetchedCount == 0 &&
state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false
Expand All @@ -230,16 +276,26 @@ extension PrivatePaykitService {
if scheduleRetries {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
}
} else if scheduleRetries, await shouldRetryMissingPrivateLightningEndpoint(for: normalizedKey, wallet: wallet) {
schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet)
} else {
cancelPendingPublicationRetry(for: normalizedKey)
}
} catch {
if scheduleRetries {
schedulePendingPublicationRetry(for: retryPublicKey, wallet: wallet)
}
if firstError == nil {
firstError = error
}
Logger.warn(
"Failed to \(reason) private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)",
"Failed to \(reason) private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(retryPublicKey)): \(error)",
context: "PrivatePaykit"
)
}
}

return firstError
}

func schedulePendingPublicationRetry(
Expand Down Expand Up @@ -274,9 +330,13 @@ extension PrivatePaykitService {
)

let contactState = state.contacts[publicKey]
let needsAnotherRetry = contactState?.linkCompletedAt == nil ||
let needsAnotherRetryFromLinkState = contactState?.linkCompletedAt == nil ||
contactState?.lastLocalPayloadHash == nil ||
contactState?.remoteEndpoints.isEmpty != false
var needsAnotherRetry = needsAnotherRetryFromLinkState
if !needsAnotherRetry {
needsAnotherRetry = await shouldRetryMissingPrivateLightningEndpoint(for: publicKey, wallet: wallet)
}
if needsAnotherRetry {
schedulePendingPublicationRetry(for: publicKey, wallet: wallet, remainingAttempts: remainingAttempts - 1)
}
Expand Down Expand Up @@ -361,7 +421,6 @@ extension PrivatePaykitService {
try await PubkyService.setPrivatePayments(linkId: linkId, entries: removalEntries)
try ensureCurrentGeneration(generation)
state.contacts[publicKey]?.lastLocalPayloadHash = nil
state.contacts[publicKey]?.localInvoice = nil
try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation)
let ownPublicKey = await (PubkyService.currentPublicKey()).flatMap(PubkyPublicKeyFormat.normalized)
if let ownPublicKey {
Expand Down
Loading
Loading