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..86e18d2b6 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" @@ -1368,6 +1370,7 @@ struct AppScene: View { // to display balances (MoneyText returns "0" if rates are nil) Task { 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 c1117fe38..a08c8410c 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. @@ -89,8 +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.approveAuth( + 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 sdkService.approveAuth( authUrl: authUrl, expectedCapabilities: expectedCapabilities, approvedClientID: approvedClientID, @@ -98,7 +110,9 @@ enum PubkyService { ) } - static func approveRingAuth(authUrl: String, secretKeyHex: String) async throws { + 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) } @@ -108,9 +122,12 @@ enum PubkyService { authUrl: String, approvedClientID: String, unsignedPayload: Data, - secretKeyHex: String + secretKeyHex: String, + sdkService: PaykitSdkService = .shared ) async throws { - try await PaykitSdkService.shared.approveAuthWithCompanionClaim( + try await sdkService.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try Task.checkCancellation() + try await sdkService.approveAuthWithCompanionClaim( authUrl: authUrl, expectedCapabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, approvedClientID: approvedClientID, @@ -313,7 +330,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 +344,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 { + Task { await republishIdentityIfNeeded() } try await operationLock.withLock { var sdk = try handle() do { @@ -362,6 +384,58 @@ actor PaykitSdkService { } } + 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 { + do { + try await Task.sleep(for: timeout) + } catch { + return + } + Logger.warn("Stopped waiting for Pubky identity republishing", context: "PaykitSdkService") + 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 } + + 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 +1192,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 +1261,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 +1275,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..597b12c01 --- /dev/null +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -0,0 +1,207 @@ +@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 + } + + 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 + + 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) + } + + 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() + } +} 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.