Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
6d68812
feat: share paykit state across apps
ben-kaufman Oct 1, 2026
7ae5bee
chore: rename changelog fragment
ben-kaufman Oct 1, 2026
10de74f
docs: clarify paykit integration contracts
ben-kaufman Oct 1, 2026
9190f18
fix: match paykit contacts to receiving addresses
ben-kaufman Oct 1, 2026
8d81890
fix: report private paykit cleanup failures
ben-kaufman Oct 1, 2026
6ec60b7
fix: guard shared Paykit payments and contact attribution
ben-kaufman Oct 1, 2026
0982385
test: cover one-time request ownership across installs
ben-kaufman Oct 1, 2026
c112976
fix: prune paykit acceptance records and test contact backfill
ben-kaufman Oct 1, 2026
0231e81
test: cover recovery of locally accepted paykit requests
ben-kaufman Oct 1, 2026
b34d564
fix: preserve paykit payment recovery and contact choices
ben-kaufman Oct 1, 2026
bdd9344
test: align contact import with shared paykit storage
ben-kaufman Oct 1, 2026
cf55252
chore: update Paykit to rc59
ben-kaufman Oct 1, 2026
17e73a0
fix: preserve concurrent paykit acceptance intents
ben-kaufman Oct 1, 2026
38dee7f
fix: preserve private sharing settings during contact cleanup
ben-kaufman Oct 1, 2026
99e84eb
Merge branch 'master' into codex/paykit-shared-runtime-local-20260930
ben-kaufman Oct 1, 2026
1ac9f07
docs: align request journey with shared paykit
ben-kaufman Oct 1, 2026
61203c8
fix: retain disabled contact sharing and cache Paykit reads
ben-kaufman Oct 1, 2026
05c3234
fix: refresh cached Paykit keys after identity failures
ben-kaufman Oct 1, 2026
ef1f2ab
fix: keep private message sync off frequent request polls
ben-kaufman Oct 2, 2026
225ec3e
Merge master into codex/paykit-shared-runtime-local-20260930
ben-kaufman Oct 2, 2026
ac90e65
fix: allow fixed-address recurring paykit payments
ben-kaufman Oct 2, 2026
48b6b8f
Merge branch 'master' into codex/paykit-shared-runtime-local-20260930
ben-kaufman Oct 2, 2026
2f9afe0
test: align profile fixture with shared storage
ben-kaufman Oct 2, 2026
f2d69b3
fix: preserve paykit recovery and payment details
ben-kaufman Oct 2, 2026
0e7da00
fix: stop redundant paykit polling
ben-kaufman Oct 2, 2026
c5ae314
fix: guard paykit payment authorization and cleanup
ben-kaufman Oct 2, 2026
f8f45ca
fix: yield between paykit handshake advances
ben-kaufman Oct 2, 2026
d78aaa3
fix: consolidate paykit background retries
ben-kaufman Oct 2, 2026
dc785b4
fix: receive Paykit messages on each inbox poll
ben-kaufman Oct 2, 2026
89c4fd8
fix: report private endpoint withdrawal failures
ben-kaufman Oct 2, 2026
27e921a
fix: coalesce paykit preparation and reduce inbox polling
ben-kaufman Oct 2, 2026
9a52ba0
fix: coordinate private contact preparation and request discovery
ben-kaufman Oct 2, 2026
778d9f2
fix: skip contact loading when disabling sharing
ben-kaufman Oct 2, 2026
f5ca801
fix: avoid contact preparation waits for subscription notifications
ben-kaufman Oct 2, 2026
21a7a60
fix: stop publication before withdrawing contact payments
ben-kaufman Oct 2, 2026
4dd4d60
perf: limit proposal discovery to the selected contact
ben-kaufman Oct 2, 2026
157e04c
fix: reduce redundant paykit synchronization
ben-kaufman Oct 3, 2026
c334a6f
fix: keep contact payments responsive during background sync
ben-kaufman Oct 3, 2026
10b85a1
fix: adopt Paykit rc60 and preserve contact eligibility
ben-kaufman Oct 3, 2026
39b2d62
perf: use rc61 and separate inbox polling from maintenance
ben-kaufman Oct 3, 2026
134dce1
chore: update Paykit to rc62
ben-kaufman Oct 3, 2026
7bdfe8d
fix: refresh requests after payment state changes
ben-kaufman Oct 4, 2026
97fbbf5
Batch private payment withdrawal and skip idle drains
ben-kaufman Oct 5, 2026
8b51eb4
Publish Paykit app once when enabling sharing
ben-kaufman Oct 5, 2026
ee467cf
Avoid duplicate publication during receive refresh
ben-kaufman Oct 5, 2026
f2349c9
Avoid redundant Paykit session and backup reads
ben-kaufman Oct 5, 2026
ec2071f
Skip linked-peer handshake advancement during drains
ben-kaufman Oct 5, 2026
89b1c24
Document foreground Paykit inbox polling
ben-kaufman Oct 5, 2026
e2b7326
Merge master and preserve shared Paykit publication guards
ben-kaufman Oct 5, 2026
f23151b
fix: refresh Paykit requests after contact changes
ben-kaufman Oct 5, 2026
407c282
fix: preserve Paykit cleanup and request presentation state
ben-kaufman Oct 5, 2026
897740b
fix: preserve Paykit presentation and queue ordering
ben-kaufman Oct 5, 2026
cd921ac
fix: preserve hardware wallet contact detachment on restore
ben-kaufman Oct 5, 2026
47e2745
fix: skip invoice refresh on Paykit request screens
ben-kaufman Oct 5, 2026
7953166
Merge master into shared Paykit integration
ben-kaufman Oct 5, 2026
e3cae1a
Merge master into shared Paykit integration
ben-kaufman Oct 5, 2026
2de4fd5
fix: reconcile shared proposal delivery status
ben-kaufman Oct 5, 2026
486d795
Run public payment resolution outside the mutation queue
ben-kaufman Oct 5, 2026
976538f
Prioritize request delivery status checks
ben-kaufman Oct 5, 2026
f361e34
fix: retry pending sharing cleanup on reconnect
ben-kaufman Oct 5, 2026
060d7a0
fix: prepare incoming payments in the send sheet
ben-kaufman Oct 6, 2026
ec786b1
fix: reject unavailable prepared payment requests
ben-kaufman Oct 6, 2026
9cb87b8
fix: prioritize incoming requests during polling
ben-kaufman Oct 6, 2026
1d49735
fix: retain due reminders until request refresh succeeds
ben-kaufman Oct 6, 2026
00e8d6b
docs: clarify reminder checks and summarize paykit changes
ben-kaufman Oct 6, 2026
39a97f4
fix: prioritize foreground sharing withdrawal completion
ben-kaufman Oct 6, 2026
6c86c83
fix: keep request preparation stable through retries
ben-kaufman Oct 6, 2026
591ac12
perf: adopt Paykit rc63 runtime updates
ben-kaufman Oct 6, 2026
90799f2
Prioritize manual payments over pending subscription reminders
ben-kaufman Oct 6, 2026
f09f1c5
fix: coalesce failed pubky recovery attempts
ben-kaufman Oct 6, 2026
32fcc1d
fix: batch profile deletion and use paykit rc64
ben-kaufman Oct 6, 2026
f79cfcb
fix: restore contacts atomically with Paykit rc65
ben-kaufman Oct 6, 2026
f553d46
Correct recovery accounting and cover partial contact removal
ben-kaufman Oct 6, 2026
a223fad
Enforce absolute Paykit deadlines before payment submission
ben-kaufman Oct 6, 2026
a684677
Merge master into codex/ios-reimport-20261006
ben-kaufman Oct 6, 2026
cb23093
fix: retain pending links after publication failure
ben-kaufman Oct 7, 2026
a04232f
fix: streamline Paykit payment acceptance with rc66
ben-kaufman Oct 7, 2026
ec6d7f6
Preserve pending wallet backups on cancellation
ben-kaufman Oct 7, 2026
fea84da
fix: coalesce background Paykit link preparation
ben-kaufman Oct 7, 2026
2a70766
fix: handle cleared LNURL confirmation state
ben-kaufman Oct 7, 2026
31c74d8
chore: update paykit to rc67
ben-kaufman Oct 7, 2026
76f8a47
fix: defer queued peer delivery during payment
ben-kaufman Oct 7, 2026
5dd730b
chore: update paykit to rc68
ben-kaufman Oct 7, 2026
7e6bc13
Recover uncertain hardware Paykit payments by wallet
ben-kaufman Oct 7, 2026
05ec22d
Document hardware payment recovery fault-injection checks
ben-kaufman Oct 7, 2026
d99d5a6
fix: preserve payment recovery and contact navigation
ben-kaufman Oct 7, 2026
291328d
fix: handle hardware failures and deleted contact navigation
ben-kaufman Oct 7, 2026
b633684
chore: update Paykit to rc69
ben-kaufman Oct 7, 2026
480b8d2
test: allow contact load setup on busy runners
ben-kaufman Oct 7, 2026
3420760
fix: retry deferred sessions before long backoff
ben-kaufman Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1178,7 +1178,7 @@
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = exactVersion;
version = "0.1.0-rc56";
version = "0.1.0-rc69";
Comment thread
ovitrif marked this conversation as resolved.
};
};
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.

560 changes: 394 additions & 166 deletions Bitkit/AppScene.swift

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions Bitkit/BitkitApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,24 @@ struct PaykitSubscriptionNotificationTarget: Codable, Equatable {
let payerIdentity: String
let paymentRequestId: String
let counterparty: String
let counterpartyReceiverPath: String
let billingPeriodStartsAt: String

init?(userInfo: [AnyHashable: Any]) {
guard let payerIdentity = userInfo["payer_identity"] as? String,
let paymentRequestId = userInfo["payment_request_id"] as? String,
let counterparty = userInfo["counterparty"] as? String,
let counterpartyReceiverPath = userInfo["counterparty_receiver_path"] as? String,
let billingPeriodStartsAt = userInfo["billing_period_starts_at"] as? String
else { return nil }

self.payerIdentity = payerIdentity
self.paymentRequestId = paymentRequestId
self.counterparty = counterparty
self.counterpartyReceiverPath = counterpartyReceiverPath
self.billingPeriodStartsAt = billingPeriodStartsAt
}

func matches(_ request: PaykitPaymentRequest) -> Bool {
paymentRequestId == request.paymentRequestId &&
PubkyPublicKeyFormat.matches(counterparty, request.counterparty) &&
counterpartyReceiverPath == request.counterpartyReceiverPath &&
request.billingPeriod.map {
PaykitSubscriptionTimestamp.string(from: $0.startsAt) == billingPeriodStartsAt
} == true
Expand All @@ -43,7 +39,6 @@ struct PaykitSubscriptionNotificationTarget: Codable, Equatable {
func matches(_ requestId: PaykitPaymentRequest.ID) -> Bool {
paymentRequestId == requestId.paymentRequestId &&
PubkyPublicKeyFormat.matches(counterparty, requestId.counterparty) &&
counterpartyReceiverPath == requestId.counterpartyReceiverPath &&
requestId.billingPeriodStartsAt.map {
PaykitSubscriptionTimestamp.string(from: $0) == billingPeriodStartsAt
} == true
Expand Down
15 changes: 15 additions & 0 deletions Bitkit/Extensions/BroadcastError+Connectivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ extension BroadcastError {
}

extension Error {
func isDefiniteHardwarePreBroadcastFailure() -> Bool {
if let error = self as? BroadcastError {
switch error {
case .InvalidHex, .InvalidTransaction:
return true
default:
return false
}
}
if let error = self as? AppError, let underlyingError = error.underlyingError {
return underlyingError.isDefiniteHardwarePreBroadcastFailure()
}
return false
}

func isBroadcastConnectivityFailure() -> Bool {
if let broadcastError = self as? BroadcastError {
return broadcastError.isConnectivityFailure
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ struct MainNavView: View {
) {
navigation.navigate(route)
if case let .contactDetail(publicKey) = route {
await contactsManager.refreshContactReceiverPaths(publicKey: publicKey, wallet: wallet)
await contactsManager.refreshContactLink(publicKey: publicKey, wallet: wallet)
}
clipboardUri = nil
return
Expand Down
119 changes: 40 additions & 79 deletions Bitkit/Managers/ContactsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,12 @@
private let fetchRemoteProfile: @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile?
private let saveContactLabel: @Sendable (_ publicKey: String, _ label: String, _ expectedIdentity: String) async throws -> Void
private let removeContactRecord: @Sendable (_ publicKey: String) async throws -> Void
private let removeContactRecords: @Sendable (_ publicKeys: [String]) async throws -> [ContactRecord]
private let forgetRemovedContacts: @Sendable (_ publicKeys: [String]) async -> Void
private let currentDate: @Sendable () -> Date

init(
contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords,

Check warning on line 158 in Bitkit/Managers/ContactsManager.swift

View workflow job for this annotation

GitHub Actions / Run Tests

converting non-sendable function value to '@sendable () async throws -> [ContactRecord]' may introduce data races

Check warning on line 158 in Bitkit/Managers/ContactsManager.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

converting non-sendable function value to '@sendable () async throws -> [ContactRecord]' may introduce data races
fetchFollows: @escaping @Sendable (String) async throws -> [String] = { try await PubkyService.getContacts(publicKey: $0) },
fetchRemoteProfile: @escaping @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? = {
try await ContactsManager.remoteContactProfile(publicKey: $0, priority: $1)
Expand All @@ -165,6 +166,9 @@
removeContactRecord: @escaping @Sendable (_ publicKey: String) async throws -> Void = {
_ = try await PubkyService.removeContact(publicKey: $0)
},
removeContactRecords: @escaping @Sendable (_ publicKeys: [String]) async throws -> [ContactRecord] = {
try await PubkyService.removeContacts(publicKeys: $0)
},
forgetRemovedContacts: @escaping @Sendable (_ publicKeys: [String]) async -> Void = {
await PrivatePaykitService.shared.removeSavedContacts(publicKeys: $0)
},
Expand All @@ -175,6 +179,7 @@
self.fetchRemoteProfile = fetchRemoteProfile
self.saveContactLabel = saveContactLabel
self.removeContactRecord = removeContactRecord
self.removeContactRecords = removeContactRecords
self.forgetRemovedContacts = forgetRemovedContacts
self.currentDate = currentDate
}
Expand Down Expand Up @@ -598,11 +603,9 @@
try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true, retryTransient: true)
}

let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey)
_ = try await PubkyService.saveContact(
publicKey: prefixedKey,
label: profile.name,
receiverPaths: receiverPaths,
restorePrivateConnection: true
)

Expand All @@ -614,25 +617,23 @@
contacts.sort(by: Self.isOrderedByName)
}

func refreshContactReceiverPaths(publicKey: String, wallet: WalletViewModel) async {
func refreshContactLink(publicKey: String, wallet: WalletViewModel) async {
guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey),
let contact = contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) })
else { return }

do {
let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey)
_ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name, receiverPaths: receiverPaths)
await PrivatePaykitService.shared.startInitialLinkBurst(
_ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name)
await PrivatePaykitService.shared.refreshSavedContactEndpoints(
for: [prefixedKey],
savedPublicKeys: contacts.map(\.publicKey),
wallet: wallet,
reason: "contact receiver refresh"
wallet: wallet
)
} catch is CancellationError {
return
} catch {
Logger.warn(
"Failed to refresh contact receiver paths for \(PubkyPublicKeyFormat.redacted(prefixedKey)): \(error)",
"Failed to refresh contact link for \(PubkyPublicKeyFormat.redacted(prefixedKey)): \(error)",
context: "ContactsManager"
)
}
Expand All @@ -643,41 +644,33 @@
/// Remembers the profiles it saves for this session, so the Contacts list shows them at once rather than only their
/// saved labels. A placeholder for a follow whose lookup failed is not remembered, so that profile is still looked up.
/// An import outlives its screens, so a reset or another identity's load while it runs, such as after a sign-out,
/// stops it quietly: it saves nothing more, adds nothing to the next session's list and reports no error.
/// drops its result quietly: it adds nothing to the next session's list and reports no error.
func importContacts(
contacts selected: [PubkyContact],
saveContact: (String, String) async throws -> Void = { publicKey, label in
_ = try await PubkyService.saveContact(publicKey: publicKey, label: label, restorePrivateConnection: true)
saveContacts: ([ContactUpdate], String?) async throws -> Void = { updates, expectedIdentity in
_ = try await PubkyService.saveContacts(updates: updates, expectedIdentity: expectedIdentity)
}
) async throws {
let profilesGeneration = resolvedProfilesGeneration
var imported: [PubkyContact] = []
let expectedIdentity = resolvedProfilesOwner
var existingKeys = Set(contacts.map(\.publicKey))
var firstError: Error?
let imported = selected.filter { existingKeys.insert($0.publicKey).inserted }
try Task.checkCancellation()
guard !imported.isEmpty else { return }

for contact in selected {
do {
try await saveContacts(imported.map { ContactUpdate(publicKey: $0.publicKey, label: $0.displayName) }, expectedIdentity)
} catch is CancellationError {
throw CancellationError()
} catch {
try Task.checkCancellation()
guard profilesGeneration == resolvedProfilesGeneration else { break }
guard !existingKeys.contains(contact.publicKey) else { continue }
do {
// The preview already resolved this profile. Receiver discovery runs during contact refresh.
try await saveContact(contact.publicKey, contact.displayName)
imported.append(contact)
existingKeys.insert(contact.publicKey)
} catch is CancellationError {
throw CancellationError()
} catch {
firstError = firstError ?? error
Logger.warn(
"Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(contact.publicKey))': \(error)",
context: "ContactsManager"
)
}
guard profilesGeneration == resolvedProfilesGeneration else { return }
throw error
}

try Task.checkCancellation()
guard profilesGeneration == resolvedProfilesGeneration else {
Logger.info("Stopped a contact import that a reset overtook after \(imported.count) saves", context: "ContactsManager")
Logger.info("Discarded a contact import that a reset overtook", context: "ContactsManager")
return
}
for contact in imported {
Expand All @@ -686,9 +679,6 @@
let currentKeys = Set(contacts.map(\.publicKey))
contacts = (contacts + imported.filter { !currentKeys.contains($0.publicKey) }).sorted(by: Self.isOrderedByName)
Logger.info("Imported \(imported.count) new contacts", context: "ContactsManager")
if let firstError {
throw firstError
}
}

// MARK: - Update Contact
Expand Down Expand Up @@ -867,7 +857,7 @@
stopProfileRefresh()
stopPendingProfileLookups()
let contactRecords = contactRecords
let removeContactRecord = removeContactRecord
let removeContactRecords = removeContactRecords
let records: [ContactRecord]
do {
records = try await Task.detached {
Expand All @@ -885,35 +875,17 @@
return
}

var deletedKeys = Set<String>()
var firstError: Error?

for record in records {
guard let contactKey = PubkyPublicKeyFormat.normalized(record.publicKey) else { continue }
do {
try await Task.detached {
try await removeContactRecord(contactKey)
}.value
deletedKeys.insert(contactKey)
} catch {
firstError = firstError ?? error
Logger.warn("Failed to delete contact '\(PubkyPublicKeyFormat.redacted(contactKey))': \(error)", context: "ContactsManager")
}
}

if let firstError {
if !deletedKeys.isEmpty {
await forgetRemovedContacts(Array(deletedKeys))
for publicKey in deletedKeys {
Self.removeContactProfileOverride(publicKey: publicKey)
}
contacts.removeAll { deletedKeys.contains($0.publicKey) }
}
throw firstError
let keys = records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) }
let removed = try await Task.detached { try await removeContactRecords(keys) }.value
let deletedKeys = Set(removed.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) })
await forgetRemovedContacts(Array(deletedKeys))
Self.removeContactProfileOverrides(publicKeys: deletedKeys)
contacts.removeAll { deletedKeys.contains($0.publicKey) }
guard deletedKeys.isSuperset(of: keys) else {
throw PrivatePaykitError.privateUnavailable
}

// All remote deletes succeeded, so clear any local-only contacts too.
await forgetRemovedContacts(Array(deletedKeys))
Self.clearContactProfileOverrides()
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
contacts.removeAll()
Expand Down Expand Up @@ -1160,20 +1132,6 @@
return true
}

private nonisolated static func relevantReceiverPaths(for publicKey: String) async throws -> [String] {
do {
return try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey)
} catch is CancellationError {
throw CancellationError()
} catch {
Logger.warn(
"Failed to discover Paykit receivers for '\(PubkyPublicKeyFormat.redacted(publicKey))': \(error)",
context: "ContactsManager"
)
return [PaykitReceiverPath.wallet]
}
}

private nonisolated static let contactProfileOverridesKey = "pubkyContactProfileOverrides"

private nonisolated static func isOrderedByName(_ lhs: PubkyContact, _ rhs: PubkyContact) -> Bool {
Expand Down Expand Up @@ -1211,9 +1169,12 @@

private nonisolated static func removeContactProfileOverride(publicKey: String) {
guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return }
var overrides = loadContactProfileOverrides()
overrides.removeValue(forKey: prefixedKey)
saveContactProfileOverrides(overrides)
removeContactProfileOverrides(publicKeys: [prefixedKey])
}

private nonisolated static func removeContactProfileOverrides(publicKeys: Set<String>) {
guard !publicKeys.isEmpty else { return }
saveContactProfileOverrides(loadContactProfileOverrides().filter { !publicKeys.contains($0.key) })
}

private nonisolated static func clearContactProfileOverrides() {
Expand Down
5 changes: 3 additions & 2 deletions Bitkit/Managers/HwWalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1541,10 +1541,11 @@ final class HwWalletManager {
}

/// Broadcast a signed funding transaction and return its txid. Does not require a connected device.
func broadcastFunding(serializedTx: String) async throws -> String {
func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String {
try await OnChainHwService.shared.broadcastRawTx(
serializedTx: serializedTx,
electrumUrl: electrumUrlProvider()
electrumUrl: electrumUrlProvider(),
paymentDeadline: paymentDeadline
)
}

Expand Down
Loading
Loading