Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 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
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-rc62";
};
};
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.

192 changes: 105 additions & 87 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
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
30 changes: 6 additions & 24 deletions Bitkit/Managers/ContactsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -587,11 +587,9 @@ class ContactsManager: ObservableObject {
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 @@ -603,25 +601,23 @@ class ContactsManager: ObservableObject {
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 Down Expand Up @@ -649,7 +645,7 @@ class ContactsManager: ObservableObject {
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.
// The preview already resolved this profile.
try await saveContact(contact.publicKey, contact.displayName)
imported.append(contact)
existingKeys.insert(contact.publicKey)
Expand Down Expand Up @@ -1148,20 +1144,6 @@ class ContactsManager: ObservableObject {
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
31 changes: 25 additions & 6 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import enum Paykit.PaykitError
import struct Paykit.PubkySessionBootstrapResult
import SwiftUI

Expand Down Expand Up @@ -1163,11 +1164,8 @@ class PubkyProfileManager: ObservableObject {
await PrivatePaykitService.shared.closeAndClear()
await PrivatePaykitAddressReservationStore.shared.clearContactAssignments()
await PubkyImageCache.shared.clear()
UserDefaults.standard.removeObject(forKey: cachedNameKey)
UserDefaults.standard.removeObject(forKey: cachedImageUriKey)
UserDefaults.standard.removeObject(forKey: cachedProfileOwnerKey)
clearCachedIdentityMetadata()
UserDefaults.standard.removeObject(forKey: profileSetupPendingKey)
ContactsManager.restoreContactProfileOverrides(nil)
clearPublicPaykitSharingState()
notifyAppStateBackupChanged()
}
Expand All @@ -1193,9 +1191,9 @@ class PubkyProfileManager: ObservableObject {
}

do {
try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false, privateSharingEnabled: false)
try await PublicPaykitService.syncPaykitApp(privateSharingEnabled: false)
} catch PubkyServiceError.sessionNotActive {
Logger.debug("Skipping Paykit receiver marker cleanup because no session is active", context: context)
Logger.debug("Skipping Paykit app capability update because no session is active", context: context)
} catch {
firstError = firstError ?? error
}
Expand Down Expand Up @@ -1310,6 +1308,7 @@ class PubkyProfileManager: ObservableObject {
// MARK: - Cached Profile Metadata

private static let cachedNameKey = "pubky_profile_name"
private static let cachedIdentityKey = "pubky_profile_identity"
private static let cachedImageUriKey = "pubky_profile_image_uri"
private static let cachedProfileOwnerKey = "pubky_profile_owner"
private static let profileSetupPendingKey = "pubky_profile_setup_pending"
Expand Down Expand Up @@ -1341,10 +1340,20 @@ class PubkyProfileManager: ObservableObject {
cachedProfileOwner = profile.publicKey
UserDefaults.standard.set(profile.name, forKey: Self.cachedNameKey)
UserDefaults.standard.set(profile.imageUrl, forKey: Self.cachedImageUriKey)
UserDefaults.standard.set(profile.publicKey, forKey: Self.cachedIdentityKey)
UserDefaults.standard.set(profile.publicKey, forKey: Self.cachedProfileOwnerKey)
}

static func activateCachedIdentity(publicKey: String, previousPublicKey: String?) {
let owner = UserDefaults.standard.string(forKey: cachedIdentityKey) ?? previousPublicKey
if let owner, !PubkyPublicKeyFormat.matches(owner, publicKey) {
clearCachedIdentityMetadata()
}
UserDefaults.standard.set(publicKey, forKey: cachedIdentityKey)
}

static func clearCachedIdentityMetadata() {
UserDefaults.standard.removeObject(forKey: cachedIdentityKey)
UserDefaults.standard.removeObject(forKey: cachedNameKey)
UserDefaults.standard.removeObject(forKey: cachedImageUriKey)
UserDefaults.standard.removeObject(forKey: cachedProfileOwnerKey)
Expand All @@ -1364,6 +1373,7 @@ class PubkyProfileManager: ObservableObject {
cachedProfileOwner = nil
UserDefaults.standard.removeObject(forKey: Self.cachedNameKey)
UserDefaults.standard.removeObject(forKey: Self.cachedImageUriKey)
UserDefaults.standard.removeObject(forKey: Self.cachedIdentityKey)
UserDefaults.standard.removeObject(forKey: Self.cachedProfileOwnerKey)
}

Expand Down Expand Up @@ -1653,6 +1663,15 @@ class PubkyProfileManager: ObservableObject {
let publicKey = try await importSession(savedSessionSecret)
return .restored(publicKey: publicKey)
} catch {
if let error = error as? PaykitError {
switch error {
case .ConcurrentUpdate, .SharedStateBusy, .Transport:
Logger.warn("Deferred session restoration, keeping saved session", context: "PubkyProfileManager")
return .restorationFailed
default:
break
}
}
Logger.warn("Failed to import saved session, attempting re-sign-in: \(error)", context: "PubkyProfileManager")
}
}
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Managers/ScannerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ class ScannerManager: ObservableObject {
let wallet
{
Task {
await contactsManager.refreshContactReceiverPaths(publicKey: publicKey, wallet: wallet)
await contactsManager.refreshContactLink(publicKey: publicKey, wallet: wallet)
}
}
return true
Expand Down
7 changes: 4 additions & 3 deletions Bitkit/Models/PaykitPaymentStateBackup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,23 @@ import Paykit
struct PaykitPaymentStateBackup: Codable {
let subscriptions: [String: Subscription]
let pendingProofs: [Proof]
var acceptedOneTimeRequests: [String: [RequestID]]? = nil

struct RequestID: Codable {
let paymentRequestId: String
let counterparty: String
let counterpartyReceiverPath: String
let billingPeriodStartsAt: String?

init(_ id: PaykitPaymentRequest.ID, billingPeriod: PaykitBillingPeriod?) {
paymentRequestId = id.paymentRequestId
counterparty = id.counterparty
counterpartyReceiverPath = id.counterpartyReceiverPath
billingPeriodStartsAt = billingPeriod?.sdkValue.startsAt
}

func restored(billingPeriod: PaykitBillingPeriod?) -> PaykitPaymentRequest.ID {
PaykitPaymentRequest.ID(
paymentRequestId: paymentRequestId,
counterparty: counterparty,
counterpartyReceiverPath: counterpartyReceiverPath,
billingPeriodStartsAt: billingPeriod?.startsAt
)
}
Expand Down Expand Up @@ -62,6 +60,7 @@ struct PaykitPaymentStateBackup: Codable {

let identity: String
let requestId: RequestID
let paymentAppId: String
Comment thread
ben-kaufman marked this conversation as resolved.
let paymentEndpointIdentifier: String
let kind: PaykitPaymentProofKind
let paymentStarted: Bool
Expand All @@ -76,6 +75,7 @@ struct PaykitPaymentStateBackup: Codable {
init(_ proof: PendingPaykitPaymentProof) {
identity = proof.identity
requestId = RequestID(proof.requestId, billingPeriod: proof.billingPeriod)
paymentAppId = proof.paymentAppId
paymentEndpointIdentifier = proof.paymentEndpointIdentifier
kind = proof.kind
paymentStarted = proof.paymentStarted
Expand All @@ -98,6 +98,7 @@ struct PaykitPaymentStateBackup: Codable {
return PendingPaykitPaymentProof(
identity: identity,
requestId: requestId.restored(billingPeriod: period),
paymentAppId: paymentAppId,
paymentEndpointIdentifier: paymentEndpointIdentifier,
kind: kind,
billingPeriod: period,
Expand Down
71 changes: 57 additions & 14 deletions Bitkit/Models/PubkyAuthRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,67 @@ import BitkitCore
import Foundation
import Paykit

enum PubkyAuthClaim: String, Equatable {
case watchOnlyAccountV1 = "watch-only-account-v1"
struct PubkyAuthClaim: Equatable {
private enum Item: String {
case paykitAccessV1 = "paykit-access-v1"
case watchOnlyAccountV1 = "watch-only-account-v1"
}

static let watchOnlyAccountV1 = Self(items: [.watchOnlyAccountV1])
static let paykitAccessV1 = Self(items: [.paykitAccessV1])

private let items: [Item]

private init(items: [Item]) {
self.items = items
}

init?(rawValue: String) {
let values = rawValue.split(separator: ".", omittingEmptySubsequences: false)
let items = values.compactMap { Item(rawValue: String($0)) }
guard !items.isEmpty, items.count == values.count, Set(items).count == items.count else { return nil }
self.items = items
}

var rawValue: String {
// Preserve the requested order because the SDK binds this value into the signature and relay channel.
items.map(\.rawValue).joined(separator: ".")
}

static let queryParameter = "x-bitkit-claim"
static let watchOnlyAccountCapabilities = "/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw"
private static let watchOnlyAccountCapabilitySet = Set(watchOnlyAccountCapabilities.split(separator: ",").map(String.init))
static let requiredCapabilities = Paykit.requiredSessionCapabilities()

static func matchesWatchOnlyAccountCapabilities(_ capabilities: String) -> Bool {
guard let requestedCapabilitySet = capabilitySet(capabilities) else { return false }
return requestedCapabilitySet == watchOnlyAccountCapabilitySet
var includesWatchOnlyAccount: Bool {
items.contains(.watchOnlyAccountV1)
}

var includesPaykitAccess: Bool {
items.contains(.paykitAccessV1)
}

func encode(accountPayload: Data?, paykitKey: PaykitIdentitySecretKey?) throws -> Data {
guard includesWatchOnlyAccount == (accountPayload != nil),
includesPaykitAccess == (paykitKey != nil)
else {
throw PubkyAuthRequestError.invalidUrl
}
if let accountPayload,
accountPayload.count != WatchOnlyAccountClaimCodec.payloadLength || accountPayload.first != 1
{
throw WatchOnlyAccountError.invalidExtendedPublicKey
}
var payload = accountPayload ?? Data([1])
if let paykitKey {
payload.append(contentsOf: withUnsafeBytes(of: paykitKey.keyGeneration().bigEndian, Array.init))
payload.append(paykitKey.exportBytes())
}
return payload
}

private static func capabilitySet(_ capabilities: String) -> Set<String>? {
let entries = capabilities
static func matchesRequiredCapabilities(_ capabilities: String) -> Bool {
capabilities
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespaces) }
guard !entries.contains(where: \.isEmpty) else { return nil }
return Set(entries)
.allSatisfy { $0.trimmingCharacters(in: .whitespaces) == requiredCapabilities }
}
}

Expand Down Expand Up @@ -293,15 +336,15 @@ struct PubkyAuthRequest {
throw PubkyAuthRequestError.duplicateBitkitClaim
}
guard let claimValue = claimValues.first else {
if requiresBitkitClaim || PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) {
if requiresBitkitClaim {
throw PubkyAuthRequestError.missingBitkitClaim
}
return nil
}
guard let claim = PubkyAuthClaim(rawValue: claimValue) else {
throw PubkyAuthRequestError.unsupportedBitkitClaim(claimValue)
}
guard PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) else {
guard PubkyAuthClaim.matchesRequiredCapabilities(capabilities) else {
throw PubkyAuthRequestError.invalidBitkitClaimCapabilities
}

Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Models/PubkyProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ struct PubkyProfile {
self = PubkyProfileData.from(paykitProfile: paykitProfile).toProfile(publicKey: publicKey)
}

init(resolution: Paykit.ContactProfileResolution) {
init(resolution: Paykit.ProfileResolution) {
let publicKey = Self.normalizedPublicKey(resolution.publicKey)
if let paykitProfile = resolution.paykitProfile {
self.init(publicKey: publicKey, paykitProfile: paykitProfile)
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,8 @@
"pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters.";
"pubky_auth__watch_only_intro_approve" = "Approve";
"pubky_auth__watch_only_intro_description" = "To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds.";
"pubky_auth__paykit_access_title" = "PRIVATE PAYKIT ACCESS";
"pubky_auth__paykit_access_description" = "Read and manage your private Paykit data, and send Paykit messages on your behalf. Your Pubky identity secret and wallet spending keys are not shared.";
"pubky_auth__watch_only_intro_relay" = "Your authorization will be delivered to <accent>{relay}</accent>.";
"pubky_auth__watch_only_intro_nav_title" = "Earn";
"pubky_auth__watch_only_intro_title" = "<accent>EARN BITCOIN</accent>\nFROM YOUR\nCONTENT";
Expand Down
Loading
Loading