From 7d5c9bbb7f0d2b66efe5b8da9bc0adbf85ebdc87 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 15 Sep 2026 17:23:04 +0300 Subject: [PATCH 1/5] fix: republish pubky identity records --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/AppScene.swift | 3 + Bitkit/Services/PubkyService.swift | 61 +++++++++++--- BitkitTests/PubkyIdentityRepublishTests.swift | 81 +++++++++++++++++++ changelog.d/next/753.fixed.md | 1 + 6 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 BitkitTests/PubkyIdentityRepublishTests.swift create mode 100644 changelog.d/next/753.fixed.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index cc7142011..191c32332 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1182,7 +1182,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc54"; + version = "0.1.0-rc55"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2bc650a24..ce1844188 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "29541375a98e13d7c6d8a072a88b2673f560944f", - "version" : "0.1.0-rc54" + "revision" : "4613beee25d1680ed8bc849446498e9bfb2cdb16", + "version" : "0.1.0-rc55" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 643807359..110ea81c5 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1036,6 +1036,7 @@ struct AppScene: View { private func pollIncomingPaykitPaymentRequests() async { guard scenePhase == .active else { return } + if network.isConnected { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } var schedule = PaykitPaymentRequestPollingSchedule() while !Task.isCancelled { do { @@ -1045,6 +1046,7 @@ struct AppScene: View { } let refreshMaintenance = schedule.takeMaintenanceIfDue() if refreshMaintenance { + if network.isConnected { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( wallet: wallet, reason: "payment request polling" @@ -1367,6 +1369,7 @@ struct AppScene: View { // Refresh currency rates when network is restored - critical for UI // to display balances (MoneyText returns "0" if rates are nil) Task { + if scenePhase == .active { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } await currency.refresh() if PaykitFeatureFlags.isUIEnabled { let contactPublicKeys = contactsManager.contacts.map(\.publicKey) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index c1117fe38..9c18ec1e7 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -47,6 +47,10 @@ enum PubkyService { try await PaykitSdkService.shared.initialize() } + static func republishIdentityIfNeeded(publicKey: String? = nil) async { + await PaykitSdkService.shared.republishIdentityIfNeeded(publicKey: publicKey) + } + // MARK: - Session Management /// Import a session secret into paykit and return the public key. @@ -90,6 +94,8 @@ enum PubkyService { /// Approve a pubkyauth:// request using the local secret key. static func approveAuth(authUrl: String, expectedCapabilities: String, approvedClientID: String, secretKeyHex: String) async throws { + try await PaykitSdkService.shared.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try Task.checkCancellation() try await PaykitSdkService.shared.approveAuth( authUrl: authUrl, expectedCapabilities: expectedCapabilities, @@ -99,6 +105,8 @@ enum PubkyService { } static func approveRingAuth(authUrl: String, secretKeyHex: String) async throws { + try await PaykitSdkService.shared.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try Task.checkCancellation() try await ServiceQueue.background(.core) { try await BitkitCore.approvePubkyAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) } @@ -110,6 +118,8 @@ enum PubkyService { unsignedPayload: Data, secretKeyHex: String ) async throws { + try await PaykitSdkService.shared.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try Task.checkCancellation() try await PaykitSdkService.shared.approveAuthWithCompanionClaim( authUrl: authUrl, expectedCapabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, @@ -313,7 +323,7 @@ enum PubkyService { // MARK: - Paykit SDK Runtime actor PaykitSdkService { - typealias ApprovalBootstrapFactory = (String, PubkyClientConfig) throws -> PubkySessionBootstrap + typealias BootstrapFactory = (String, PubkyClientConfig) throws -> PubkySessionBootstrap static let shared = PaykitSdkService() private static let walletBackupDataChangedSubject = PassthroughSubject() @@ -327,18 +337,23 @@ actor PaykitSdkService { private let paymentAdapter = PaykitSdkPaymentAdapter() private let operationLock = PaykitSdkOperationLock() private let pubkyClientConfig = PaykitSdkService.makePubkyClientConfig(localTestnetHost: Env.pubkyLocalTestnetHost) - private let approvalBootstrapFactory: ApprovalBootstrapFactory + private let bootstrapFactory: BootstrapFactory + private var cachedBootstrap: PubkySessionBootstrap? + private var isRepublishingIdentity = false + private var republishPublicKey: String? + private var nextIdentityRepublishAt = Date.distantPast private var sdk: PaykitSdk? private var activeAuthRequest: Paykit.PubkyAuthRequest? private var activeAuthRequestID: UUID? init( - approvalBootstrapFactory: @escaping ApprovalBootstrapFactory = PubkySessionBootstrap.withPubkyClientConfig(clientId:pubkyClient:) + bootstrapFactory: @escaping BootstrapFactory = PubkySessionBootstrap.withPubkyClientConfig(clientId:pubkyClient:) ) { - self.approvalBootstrapFactory = approvalBootstrapFactory + self.bootstrapFactory = bootstrapFactory } func initialize() async throws { + await republishIdentityIfNeeded() try await operationLock.withLock { var sdk = try handle() do { @@ -362,6 +377,33 @@ actor PaykitSdkService { } } + func republishIdentityIfNeeded(publicKey: String? = nil, now: Date = Date()) async { + guard !Task.isCancelled, !isRepublishingIdentity else { return } + isRepublishingIdentity = true + defer { isRepublishingIdentity = false } + + do { + let identity = try publicKey ?? sessionProvider.loadLocalSecretKey().map { + try Paykit.pubkyPublicKeyFromSecret(localSecretKey: $0) + } + guard let identity = identity.flatMap(PubkyPublicKeyFormat.normalized), + identity != republishPublicKey || now >= nextIdentityRepublishAt + else { return } + + republishPublicKey = identity + nextIdentityRepublishAt = now.addingTimeInterval(60) + if try await bootstrap().republishIdentity(publicKey: identity) { + nextIdentityRepublishAt = now.addingTimeInterval(30 * 60) + Logger.debug("Republished Pubky identity", context: "PaykitSdkService") + } else { + Logger.debug("Found no Pubky identity record to republish", context: "PaykitSdkService") + } + } catch is CancellationError { + } catch { + Logger.warn("Failed to republish Pubky identity: \(error)", context: "PaykitSdkService") + } + } + func currentPublicKey() async throws -> String? { try await operationLock.withLock { if let status = try await handle().identityStatus(), let publicKey = status.publicKey { @@ -1118,6 +1160,7 @@ actor PaykitSdkService { let sdk = try handle() _ = try await sdk.initialize() await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) + await republishIdentityIfNeeded(publicKey: result.publicKey) } private func publishReceiverMarkerIfLiveSessionAvailable(using sdk: PaykitSdk) async { @@ -1186,10 +1229,10 @@ actor PaykitSdkService { } private func bootstrap() throws -> PubkySessionBootstrap { - try PubkySessionBootstrap.withPubkyClientConfig( - clientId: Self.clientID, - pubkyClient: pubkyClientConfig - ) + if let cachedBootstrap { return cachedBootstrap } + let bootstrap = try bootstrapFactory(Self.clientID, pubkyClientConfig) + cachedBootstrap = bootstrap + return bootstrap } func approvalBootstrap(authUrl: String, approvedClientID: String) throws -> PubkySessionBootstrap { @@ -1200,7 +1243,7 @@ actor PaykitSdkService { debugMessage: "Approved Pubky client ID does not match auth request" ) } - return try approvalBootstrapFactory(requestClientID, pubkyClientConfig) + return try bootstrapFactory(requestClientID, pubkyClientConfig) } nonisolated static func makePubkyClientConfig(localTestnetHost: String?) -> PubkyClientConfig { diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift new file mode 100644 index 000000000..830e724e8 --- /dev/null +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -0,0 +1,81 @@ +@testable import Bitkit +import Paykit +import XCTest + +final class PubkyIdentityRepublishTests: XCTestCase { + private let publicKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private let now = Date(timeIntervalSince1970: 1000) + + func testSuccessfulPublicationIsThrottledAndReusesBootstrap() async { + let bootstrap = RepublishBootstrap(noPointer: .init()) + var factories = 0 + let service = PaykitSdkService { _, _ in + factories += 1 + return bootstrap + } + + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now) + await service.republishIdentityIfNeeded(publicKey: "pubky\(publicKey)", now: now.addingTimeInterval(1799)) + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now.addingTimeInterval(1800)) + + XCTAssertEqual(bootstrap.publicKeys, Array(repeating: "pubky\(publicKey)", count: 2)) + XCTAssertEqual(factories, 1) + } + + func testMissingRecordAndFailuresRetryWithoutWaitingForSuccessInterval() async { + for result in [Result.success(false), .failure(PubkyServiceError.profileNotFound)] { + let bootstrap = RepublishBootstrap(noPointer: .init()) + bootstrap.operation = { _ in try result.get() } + let service = PaykitSdkService { _, _ in bootstrap } + + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now) + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now.addingTimeInterval(59)) + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now.addingTimeInterval(60)) + + XCTAssertEqual(bootstrap.publicKeys.count, 2) + } + } + + func testNewIdentityDoesNotInheritPreviousIdentityThrottle() async { + let bootstrap = RepublishBootstrap(noPointer: .init()) + let service = PaykitSdkService { _, _ in bootstrap } + let otherKey = String(publicKey.dropLast()) + "y" + + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now) + await service.republishIdentityIfNeeded(publicKey: otherKey, now: now) + + XCTAssertEqual(bootstrap.publicKeys, ["pubky\(publicKey)", "pubky\(otherKey)"]) + } + + func testConcurrentTriggersDoNotOverlapPublication() async { + let started = expectation(description: "Publication started") + let gate = AsyncStream.makeStream() + let bootstrap = RepublishBootstrap(noPointer: .init()) + bootstrap.operation = { _ in + started.fulfill() + for await _ in gate.stream { + break + } + return true + } + let service = PaykitSdkService { _, _ in bootstrap } + let first = Task { await service.republishIdentityIfNeeded(publicKey: publicKey, now: now) } + await fulfillment(of: [started], timeout: 1) + + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now.addingTimeInterval(3600)) + XCTAssertEqual(bootstrap.publicKeys.count, 1) + + gate.continuation.finish() + await first.value + } +} + +private final class RepublishBootstrap: PubkySessionBootstrap, @unchecked Sendable { + var publicKeys: [String] = [] + var operation: (String) async throws -> Bool = { _ in true } + + override func republishIdentity(publicKey: String) async throws -> Bool { + publicKeys.append(publicKey) + return try await operation(publicKey) + } +} diff --git a/changelog.d/next/753.fixed.md b/changelog.d/next/753.fixed.md new file mode 100644 index 000000000..ae439640a --- /dev/null +++ b/changelog.d/next/753.fixed.md @@ -0,0 +1 @@ +Improved Pubky identity discovery by periodically refreshing existing identity records while Bitkit is open. From d76e9eb25e1d43c0099f52fff3263397f24e6270 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 15 Sep 2026 17:46:57 +0300 Subject: [PATCH 2/5] fix: bound identity republishing --- Bitkit/AppScene.swift | 2 +- Bitkit/Services/PubkyService.swift | 22 ++++++++++++- BitkitTests/PubkyIdentityRepublishTests.swift | 31 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 110ea81c5..86e18d2b6 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1369,8 +1369,8 @@ struct AppScene: View { // Refresh currency rates when network is restored - critical for UI // to display balances (MoneyText returns "0" if rates are nil) Task { - if scenePhase == .active { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } await currency.refresh() + if scenePhase == .active { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } if PaykitFeatureFlags.isUIEnabled { let contactPublicKeys = contactsManager.contacts.map(\.publicKey) await PrivatePaykitService.shared.startInitialLinkBurst( diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 9c18ec1e7..5aec9880f 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -377,7 +377,27 @@ actor PaykitSdkService { } } - func republishIdentityIfNeeded(publicKey: String? = nil, now: Date = Date()) async { + func republishIdentityIfNeeded(publicKey: String? = nil, now: Date = Date(), timeout: Duration = .seconds(5)) async { + guard !Task.isCancelled else { return } + // Swift FFI may ignore cancellation; keep the in-flight guard until publication actually finishes. + let (stream, continuation) = AsyncStream.makeStream() + let publication = Task { + await republishIdentity(publicKey: publicKey, now: now) + continuation.finish() + } + let deadline = Task { + try? await Task.sleep(for: timeout) + continuation.finish() + } + defer { + publication.cancel() + deadline.cancel() + continuation.finish() + } + for await _ in stream {} + } + + private func republishIdentity(publicKey: String?, now: Date) async { guard !Task.isCancelled, !isRepublishingIdentity else { return } isRepublishingIdentity = true defer { isRepublishingIdentity = false } diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift index 830e724e8..a9b852cf1 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -68,6 +68,37 @@ final class PubkyIdentityRepublishTests: XCTestCase { gate.continuation.finish() await first.value } + + func testSlowPublicationDoesNotHoldCallerOrOverlapRetry() async { + let started = expectation(description: "Publication started") + let returned = expectation(description: "Caller returned") + let finished = expectation(description: "Publication finished") + let gate = AsyncStream.makeStream() + let work = Task { + for await _ in gate.stream {} + return true + } + let bootstrap = RepublishBootstrap(noPointer: .init()) + bootstrap.operation = { _ in + started.fulfill() + let result = await work.value + finished.fulfill() + return result + } + let service = PaykitSdkService { _, _ in bootstrap } + let caller = Task { + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now, timeout: .milliseconds(20)) + returned.fulfill() + } + await fulfillment(of: [started, returned], timeout: 1) + + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now.addingTimeInterval(3600)) + XCTAssertEqual(bootstrap.publicKeys.count, 1) + + gate.continuation.finish() + await fulfillment(of: [finished], timeout: 1) + await caller.value + } } private final class RepublishBootstrap: PubkySessionBootstrap, @unchecked Sendable { From 236c8d0685984936ee28bed31a3a6efdc138be8c Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 15 Sep 2026 18:30:29 +0300 Subject: [PATCH 3/5] test: verify retry after slow identity publication --- BitkitTests/PubkyIdentityRepublishTests.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift index a9b852cf1..d503e9c1b 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -98,6 +98,15 @@ final class PubkyIdentityRepublishTests: XCTestCase { gate.continuation.finish() await fulfillment(of: [finished], timeout: 1) await caller.value + + bootstrap.operation = { _ in true } + let deadline = ContinuousClock.now.advanced(by: .seconds(1)) + repeat { + await service.republishIdentityIfNeeded(publicKey: publicKey, now: now.addingTimeInterval(3600)) + if bootstrap.publicKeys.count == 2 { break } + await Task.yield() + } while ContinuousClock.now < deadline + XCTAssertEqual(bootstrap.publicKeys.count, 2) } } From bf206ae9b96ba13bd7f18f9d9c7c605ac0385a1d Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 16 Sep 2026 12:43:55 +0300 Subject: [PATCH 4/5] test: cover identity republish auth callers --- Bitkit/Services/PubkyService.swift | 23 +++-- BitkitTests/PubkyIdentityRepublishTests.swift | 86 +++++++++++++++++++ 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 5aec9880f..c97e83a94 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -93,10 +93,16 @@ enum PubkyService { } /// Approve a pubkyauth:// request using the local secret key. - static func approveAuth(authUrl: String, expectedCapabilities: String, approvedClientID: String, secretKeyHex: String) async throws { - try await PaykitSdkService.shared.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + static func approveAuth( + authUrl: String, + expectedCapabilities: String, + approvedClientID: String, + secretKeyHex: String, + sdkService: PaykitSdkService = .shared + ) async throws { + try await sdkService.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) try Task.checkCancellation() - try await PaykitSdkService.shared.approveAuth( + try await sdkService.approveAuth( authUrl: authUrl, expectedCapabilities: expectedCapabilities, approvedClientID: approvedClientID, @@ -104,8 +110,8 @@ enum PubkyService { ) } - static func approveRingAuth(authUrl: String, secretKeyHex: String) async throws { - try await PaykitSdkService.shared.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + static func approveRingAuth(authUrl: String, secretKeyHex: String, sdkService: PaykitSdkService = .shared) async throws { + try await sdkService.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) try Task.checkCancellation() try await ServiceQueue.background(.core) { try await BitkitCore.approvePubkyAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) @@ -116,11 +122,12 @@ enum PubkyService { authUrl: String, approvedClientID: String, unsignedPayload: Data, - secretKeyHex: String + secretKeyHex: String, + sdkService: PaykitSdkService = .shared ) async throws { - try await PaykitSdkService.shared.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try await sdkService.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) try Task.checkCancellation() - try await PaykitSdkService.shared.approveAuthWithCompanionClaim( + try await sdkService.approveAuthWithCompanionClaim( authUrl: authUrl, expectedCapabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, approvedClientID: approvedClientID, diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift index d503e9c1b..597b12c01 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -108,14 +108,100 @@ final class PubkyIdentityRepublishTests: XCTestCase { } while ContinuousClock.now < deadline XCTAssertEqual(bootstrap.publicKeys.count, 2) } + + func testAuthRepublishesSigningIdentityBeforeApprovalEvenWhenPublicationFails() async throws { + for kind in [Approval.ordinary, .companion] { + for result in [Result.success(true), .failure(PubkyServiceError.profileNotFound)] { + let bootstrap = RepublishBootstrap(noPointer: .init()) + let service = PaykitSdkService { _, _ in bootstrap } + let expectedKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "01", count: 32)) + var approved = false + bootstrap.operation = { _ in try result.get() } + bootstrap.approval = { + XCTAssertEqual(bootstrap.publicKeys, [expectedKey]) + approved = true + } + + try await approve(kind, using: service) + + XCTAssertTrue(approved, "\(kind)") + bootstrap.approval = {} + } + } + } + + func testCancellationDuringPublicationStopsAllAuthApprovalPaths() async { + for kind in Approval.allCases { + let started = expectation(description: "Publication started for \(kind)") + let gate = AsyncStream.makeStream() + let bootstrap = RepublishBootstrap(noPointer: .init()) + let service = PaykitSdkService { _, _ in bootstrap } + bootstrap.operation = { _ in + started.fulfill() + for await _ in gate.stream {} + return true + } + bootstrap.approval = { XCTFail("Cancelled \(kind) approval must not be delivered") } + let caller = Task { try await approve(kind, using: service) } + await fulfillment(of: [started], timeout: 1) + + caller.cancel() + do { + try await caller.value + XCTFail("Cancelled \(kind) approval must throw") + } catch { + XCTAssertTrue(error is CancellationError, "\(kind): \(error)") + } + gate.continuation.finish() + } + } + + private enum Approval: CaseIterable { + case ordinary, companion, ring + } + + private func approve(_ kind: Approval, using service: PaykitSdkService) async throws { + let secretKeyHex = String(repeating: "01", count: 32) + let authUrl = "pubkyauth://signin_grant?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + + "&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + switch kind { + case .ordinary: + try await PubkyService.approveAuth( + authUrl: authUrl, expectedCapabilities: "/pub/example/:rw", approvedClientID: "paykit.test", + secretKeyHex: secretKeyHex, sdkService: service + ) + case .companion: + try await PubkyService.approveAuthWithCompanionClaim( + authUrl: authUrl, approvedClientID: "paykit.test", unsignedPayload: Data(), + secretKeyHex: secretKeyHex, sdkService: service + ) + case .ring: + try await PubkyService.approveRingAuth(authUrl: "invalid-auth-url", secretKeyHex: secretKeyHex, sdkService: service) + } + } } private final class RepublishBootstrap: PubkySessionBootstrap, @unchecked Sendable { var publicKeys: [String] = [] var operation: (String) async throws -> Bool = { _ in true } + var approval: () -> Void = {} override func republishIdentity(publicKey: String) async throws -> Bool { publicKeys.append(publicKey) return try await operation(publicKey) } + + override func approveAuth(authUrl _: String, expectedCapabilities _: String, localSecretKey _: PubkyLocalSecretKey) async throws { + approval() + } + + override func approveAuthWithCompanionClaim( + authUrl _: String, + expectedCapabilities _: String, + localSecretKey _: PubkyLocalSecretKey, + claim _: PubkyAuthCompanionClaim + ) async throws { + approval() + } } From d6bfdf09baaf699036c49659432089aaf434eee7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 16 Sep 2026 15:57:43 +0300 Subject: [PATCH 5/5] fix: keep identity republishing off the startup path --- Bitkit/Services/PubkyService.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index c97e83a94..a08c8410c 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -360,7 +360,7 @@ actor PaykitSdkService { } func initialize() async throws { - await republishIdentityIfNeeded() + Task { await republishIdentityIfNeeded() } try await operationLock.withLock { var sdk = try handle() do { @@ -393,7 +393,12 @@ actor PaykitSdkService { continuation.finish() } let deadline = Task { - try? await Task.sleep(for: timeout) + do { + try await Task.sleep(for: timeout) + } catch { + return + } + Logger.warn("Stopped waiting for Pubky identity republishing", context: "PaykitSdkService") continuation.finish() } defer {