From 2834e7a151a0a95e7321c0926951f7a12d072830 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:21:59 -0500 Subject: [PATCH 1/9] feat: support Pubky Ring signup --- Bitkit/AppScene.swift | 4 + Bitkit/MainNavView.swift | 13 ++ Bitkit/Managers/PubkyProfileManager.swift | 129 ++++++++++++++---- Bitkit/Models/PubkyAuthRequest.swift | 125 ++++++++++++++++- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Services/PubkyService.swift | 34 +++++ Bitkit/Utilities/ShopPaymentRequest.swift | 1 + Bitkit/ViewModels/AppViewModel.swift | 72 ++++++++-- Bitkit/ViewModels/SheetViewModel.swift | 4 +- .../PubkyAuthApprovalSheet.swift | 30 +++- BitkitTests/PubkyAuthRequestTests.swift | 42 +++++- BitkitTests/ShopPaymentRequestTests.swift | 38 +++++- changelog.d/next/724.added.md | 1 + 13 files changed, 436 insertions(+), 58 deletions(-) create mode 100644 changelog.d/next/724.added.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 76754898e..88067cd67 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -878,6 +878,10 @@ struct AppScene: View { wallet.resetSendState(speed: settings.defaultTransactionSpeed) return } + } catch ScanHandlingError.pubkyAuthRequest { + guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + _ = paykitPaymentRequestManager.markPresentedIfPending(request) + continue } catch is CancellationError { if app.ownsContactPaymentContext(contactPaymentContext) { app.resetSendState() diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index a1b201357..20808a331 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -25,6 +25,15 @@ struct MainNavView: View { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } + private var shouldResumePendingPubkyProfileSetup: Bool { + isPaykitUIActive && + pubkyProfile.isProfileSetupPending && + pubkyProfile.isAuthenticated && + sheets.activeSheetConfiguration == nil && + !sheets.isReplacingSheet && + navigation.currentRoute != .createProfile + } + // Delay constants for clipboard processing private static let nodeReadyDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds private static let statePropagationDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds @@ -39,6 +48,10 @@ struct MainNavView: View { navigation.navigate(.spendingHwSigned) } } + .onChange(of: shouldResumePendingPubkyProfileSetup, initial: true) { _, shouldResume in + guard shouldResume else { return } + navigation.navigate(.createProfile) + } .sheet( item: $sheets.addTagSheetItem, onDismiss: { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 2c0001981..e9d1a602b 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -121,6 +121,10 @@ private enum PubkyProfileManagerError: LocalizedError { } } +enum PubkySignupError: Error { + case alreadySignedIn +} + @MainActor class PubkyProfileManager: ObservableObject { enum SessionInitializationResult: Equatable { @@ -138,12 +142,14 @@ class PubkyProfileManager: ObservableObject { @Published var sessionRestorationFailed = false @Published private(set) var cachedName: String? @Published private(set) var cachedImageUri: String? + @Published private(set) var isProfileSetupPending: Bool private var activeAuthAttemptID: UUID? init() { cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) + isProfileSetupPending = UserDefaults.standard.bool(forKey: Self.profileSetupPendingKey) } // MARK: - Initialization & Session Restoration @@ -252,6 +258,22 @@ class PubkyProfileManager: ObservableObject { existingImageUrl: String? = nil, avatarImage: UIImage? = nil ) async throws { + if isProfileSetupPending { + guard let publicKey else { + throw PubkyServiceError.sessionNotActive + } + try await createProfile( + publicKey: publicKey, + name: name, + bio: bio, + links: links, + tags: tags, + existingImageUrl: existingImageUrl, + avatarImage: avatarImage + ) + return + } + let (publicKeyZ32, secretKeyHex) = try await deriveKeys() _ = try await Task.detached { @@ -279,35 +301,15 @@ class PubkyProfileManager: ObservableObject { }.value do { - var avatarUri: String? - if let avatarImage { - avatarUri = try await uploadAvatar(image: avatarImage) - } - let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) - - try await writeProfile( - name: name, - bio: bio, - imageUrl: resolvedImageUrl, - links: links, - tags: tags - ) - Self.notifyAppStateBackupChanged() - - let createdProfile = PubkyProfile( + try await createProfile( publicKey: publicKeyZ32, name: name, bio: bio, - imageUrl: resolvedImageUrl, links: links, tags: tags, - status: nil + existingImageUrl: existingImageUrl, + avatarImage: avatarImage ) - - publicKey = publicKeyZ32 - authState = .authenticated - profile = createdProfile - cacheProfileMetadata(createdProfile) } catch { let profileCreationError = error await discardAbandonedSession() @@ -317,6 +319,70 @@ class PubkyProfileManager: ObservableObject { Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager") } + private func createProfile( + publicKey: String, + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String], + existingImageUrl: String?, + avatarImage: UIImage? + ) async throws { + var avatarUri: String? + if let avatarImage { + avatarUri = try await uploadAvatar(image: avatarImage) + } + let imageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) + + try await writeProfile(name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags) + Self.notifyAppStateBackupChanged() + + let createdProfile = PubkyProfile( + publicKey: publicKey, + name: name, + bio: bio, + imageUrl: imageUrl, + links: links, + tags: tags, + status: nil + ) + self.publicKey = publicKey + authState = .authenticated + profile = createdProfile + cacheProfileMetadata(createdProfile) + setProfileSetupPending(false) + } + + func approveSignupAuth(request: PubkyAuthRequest) async throws { + guard request.isRingSignup, + let homeserver = request.homeserverPublicKey + else { + throw PubkyServiceError.invalidAuthUrl + } + guard publicKey == nil, try !Self.hasStoredIdentity() else { + throw PubkySignupError.alreadySignedIn + } + + let (publicKey, secretKeyHex) = try await deriveKeys() + guard self.publicKey == nil, try !Self.hasStoredIdentity() else { + throw PubkySignupError.alreadySignedIn + } + + try await PubkyService.registerIdentity( + secretKeyHex: secretKeyHex, + homeserverZ32: homeserver, + signupCode: request.signupToken + ) + try await PubkyService.approveRingAuth(authUrl: request.authorizationUrl, secretKeyHex: secretKeyHex) + setProfileSetupPending(true) + _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + Self.notifyAppStateBackupChanged() + self.publicKey = publicKey + authState = .authenticated + } + func saveProfile( name: String, bio: String, @@ -736,6 +802,7 @@ class PubkyProfileManager: ObservableObject { await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + UserDefaults.standard.removeObject(forKey: profileSetupPendingKey) ContactsManager.restoreContactProfileOverrides(nil) clearPublicPaykitSharingState() notifyAppStateBackupChanged() @@ -829,6 +896,7 @@ class PubkyProfileManager: ObservableObject { throw error } + setProfileSetupPending(false) clearAuthenticatedState() } @@ -871,6 +939,7 @@ class PubkyProfileManager: ObservableObject { private static let cachedNameKey = "pubky_profile_name" private static let cachedImageUriKey = "pubky_profile_image_uri" + private static let profileSetupPendingKey = "pubky_profile_setup_pending" var displayName: String? { profile?.name ?? cachedName @@ -894,6 +963,11 @@ class PubkyProfileManager: ObservableObject { UserDefaults.standard.removeObject(forKey: Self.cachedImageUriKey) } + private func setProfileSetupPending(_ pending: Bool) { + isProfileSetupPending = pending + UserDefaults.standard.set(pending, forKey: Self.profileSetupPendingKey) + } + private func clearAuthenticatedState() { publicKey = nil profile = nil @@ -920,6 +994,15 @@ class PubkyProfileManager: ObservableObject { Self.hasLocalSecretKey(for: publicKey) } + nonisolated static func hasStoredIdentity() throws -> Bool { + for key in [KeychainEntryType.paykitSession, .pubkySecretKey] { + if let value = try Keychain.loadString(key: key), !value.isEmpty { + return true + } + } + return false + } + nonisolated static func hasLocalSecretKey(for publicKey: String?) -> Bool { guard let publicKey, let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey), diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 4a785ca16..b41a94430 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -1,3 +1,4 @@ +import BitkitCore import Foundation import Paykit @@ -48,7 +49,7 @@ struct PubkyAuthPermission { } } -// MARK: - PubkyAuth Request (parsed from pubkyauth:// URL) +// MARK: - PubkyAuth Request struct PubkyAuthRequest { let rawUrl: String @@ -59,14 +60,90 @@ struct PubkyAuthRequest { let permissions: [PubkyAuthPermission] let serviceNames: [String] let bitkitClaim: PubkyAuthClaim? + let homeserverPublicKey: String? + let signupToken: String? + let authorizationUrl: String + + var isRingSignup: Bool { + guard let components = URLComponents(string: rawUrl) else { return false } + return components.scheme?.lowercased() == "pubkyring" && components.host?.lowercased() == "signup" + } static func isProtocolURL(_ value: String) -> Bool { - URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth" + guard let components = URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return false + } + + switch components.scheme?.lowercased() { + case "pubkyauth": + return true + case "pubkyring": + return components.host?.lowercased() == "signup" + default: + return false + } } static func parse(url: String) throws -> PubkyAuthRequest { + if let components = URLComponents(string: url), + components.scheme?.lowercased() == "pubkyring", + components.host?.lowercased() == "signup" + { + return try parseRingSignup(url: url, components: components) + } + let details = try Paykit.parsePubkyAuthUrl(authUrl: url) - let capabilities = details.capabilities ?? "" + let capabilities = details.capabilities + return try makeRequest( + url: url, + kind: details.kind, + clientID: details.clientId, + relay: details.relayUrl, + capabilities: capabilities, + homeserverPublicKey: nil, + signupToken: nil + ) + } + + private static func parseRingSignup(url: String, components: URLComponents) throws -> PubkyAuthRequest { + let values = Dictionary(grouping: components.queryItems ?? [], by: \.name) + let relay = try requiredQueryValue("relay", from: values) + let secret = try requiredQueryValue("secret", from: values) + let capabilities = try requiredQueryValue("caps", from: values) + let homeserver = try requiredQueryValue("hs", from: values) + let authorizationUrl = ringAuthorizationUrl(relay: relay, secret: secret, capabilities: capabilities) + do { + _ = try BitkitCore.parsePubkyAuthUrl(authUrl: authorizationUrl) + _ = try Paykit.normalizePubkyPublicKey(value: homeserver) + } catch { + throw PubkyAuthRequestError.invalidUrl + } + let request = try makeRequest( + url: url, + kind: .signUp, + clientID: "", + relay: relay, + capabilities: capabilities, + homeserverPublicKey: homeserver, + signupToken: optionalQueryValue("st", from: values), + authorizationUrl: authorizationUrl + ) + guard request.bitkitClaim == nil else { + throw PubkyAuthRequestError.invalidUrl + } + return request + } + + private static func makeRequest( + url: String, + kind: Paykit.PubkyAuthRequestKind, + clientID: String, + relay: String, + capabilities: String, + homeserverPublicKey: String?, + signupToken: String?, + authorizationUrl: String? = nil + ) throws -> PubkyAuthRequest { let permissions = parseCapabilities(capabilities) var seenServiceNames = Set() let serviceNames = permissions @@ -75,16 +152,50 @@ struct PubkyAuthRequest { let bitkitClaim = try parseBitkitClaim(url: url, capabilities: capabilities) return PubkyAuthRequest( rawUrl: url, - kind: details.kind, - clientID: details.clientId, - relay: details.relayUrl ?? "", + kind: kind, + clientID: clientID, + relay: relay, capabilities: capabilities, permissions: permissions, serviceNames: serviceNames, - bitkitClaim: bitkitClaim + bitkitClaim: bitkitClaim, + homeserverPublicKey: homeserverPublicKey, + signupToken: signupToken, + authorizationUrl: authorizationUrl ?? url ) } + private static func ringAuthorizationUrl(relay: String, secret: String, capabilities: String) -> String { + "pubkyauth:///?relay=\(encodeQueryComponent(relay))" + + "&secret=\(encodeQueryComponent(secret))&caps=\(encodeQueryComponent(capabilities))" + } + + private static func encodeQueryComponent(_ value: String) -> String { + let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) + return value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value + } + + private static func requiredQueryValue( + _ name: String, + from values: [String: [URLQueryItem]] + ) throws -> String { + guard let value = try optionalQueryValue(name, from: values), !value.isEmpty else { + throw PubkyAuthRequestError.invalidUrl + } + return value + } + + private static func optionalQueryValue( + _ name: String, + from values: [String: [URLQueryItem]] + ) throws -> String? { + let items = values[name] ?? [] + guard items.count <= 1 else { + throw PubkyAuthRequestError.invalidUrl + } + return items.first?.value.flatMap { $0.isEmpty ? nil : $0 } + } + static func parseBitkitClaim(url: String, capabilities: String) throws -> PubkyAuthClaim? { guard let components = URLComponents(string: url) else { throw PubkyAuthRequestError.invalidUrl diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 7fe9ba826..889472e6c 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -710,6 +710,7 @@ "pubky_auth__success_middle" = " and gave the service permission to access and edit your "; "pubky_auth__success_suffix" = " data."; "pubky_auth__biometric_failed" = "Authentication Failed"; +"pubky_auth__already_signed_in" = "Already signed in"; "pubky_auth__no_identity" = "Pubky Identity Required"; "pubky_auth__no_identity_desc" = "Create a Pubky identity in your profile to approve auth requests."; "pubky_auth__use_ring" = "Use Pubky Ring"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index da4d702ab..25a03733c 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -98,6 +98,12 @@ enum PubkyService { ) } + static func approveRingAuth(authUrl: String, secretKeyHex: String) async throws { + try await ServiceQueue.background(.core) { + try await BitkitCore.approvePubkyAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) + } + } + static func approveAuthWithCompanionClaim( authUrl: String, approvedClientID: String, @@ -224,6 +230,18 @@ enum PubkyService { return result.sessionAccess.exportSessionSecret() } + static func registerIdentity( + secretKeyHex: String, + homeserverZ32: String, + signupCode: String? = nil + ) async throws { + try await PaykitSdkService.shared.registerIdentity( + secretKeyHex: secretKeyHex, + homeserverPublicKey: homeserverZ32, + signupCode: signupCode + ) + } + /// Sign in with an existing secret key. Returns new session secret. static func signIn(secretKeyHex: String) async throws -> String { let result = try await PaykitSdkService.shared.signIn(secretKeyHex: secretKeyHex) @@ -394,6 +412,22 @@ actor PaykitSdkService { } } + func registerIdentity( + secretKeyHex: String, + homeserverPublicKey: String, + signupCode: String? + ) async throws { + try await operationLock.withLock { + _ = try await bootstrap().signUp( + localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + receiverNoiseSecretKey: sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), + homeserverPublicKey: homeserverPublicKey, + signupCode: signupCode, + requiredCapabilities: Self.requiredCapabilities() + ) + } + } + func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() diff --git a/Bitkit/Utilities/ShopPaymentRequest.swift b/Bitkit/Utilities/ShopPaymentRequest.swift index 5c014bdce..e2fa56de2 100644 --- a/Bitkit/Utilities/ShopPaymentRequest.swift +++ b/Bitkit/Utilities/ShopPaymentRequest.swift @@ -24,6 +24,7 @@ enum ShopPaymentRequest { } enum ScanHandlingError: LocalizedError { + case pubkyAuthRequest case unsupportedRequest var errorDescription: String? { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 4fb599759..dd5d02fb4 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -437,8 +437,15 @@ extension AppViewModel { } let uri = uri.removingLightningSchemes() + if let claimedContactPaymentContext, PubkyAuthRequest.isProtocolURL(uri) { + releaseContactPaymentContext(claimedContactPaymentContext) + throw ScanHandlingError.pubkyAuthRequest + } let prevalidatedPaymentRequest: BitkitCore.Scanner? if scope == .paymentRequests { + if PubkyAuthRequest.isProtocolURL(uri) { + throw ScanHandlingError.pubkyAuthRequest + } guard SamRockSetupRequest.parse(uri) == nil, !SamRockSetupRequest.isProtocolURL(uri) else { @@ -485,6 +492,23 @@ extension AppViewModel { return } + if PubkyAuthRequest.isProtocolURL(uri) { + guard scope == .unrestricted else { + throw ScanHandlingError.pubkyAuthRequest + } + guard PaykitFeatureFlags.isUIEnabled else { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" + ) + return + } + handlePubkyAuthApproval(uri) + return + } + let data: BitkitCore.Scanner if let prevalidatedPaymentRequest { data = prevalidatedPaymentRequest @@ -787,13 +811,41 @@ extension AppViewModel { } private func handlePubkyAuthApproval(_ authUrl: String) { - // State 1: No Pubky identity at all - guard (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false else { + let request: PubkyAuthRequest + + do { + request = try PubkyAuthRequest.parse(url: authUrl) + } catch { + Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") + toast(type: .error, title: t("pubky_auth__invalid_request")) + return + } + + if request.isRingSignup { + do { + guard try !PubkyProfileManager.hasStoredIdentity() else { + toast(type: .info, title: t("pubky_auth__already_signed_in")) + return + } + } catch { + Logger.error("Failed to read stored Pubky identity: \(error)", context: "AppViewModel") + toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) + return + } + + sheetViewModel.showSheet( + .pubkyAuthApproval, + data: PubkyAuthApprovalConfig(request: request) + ) + return + } + + let hasSession = (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false + guard hasSession else { toast(type: .warning, title: t("pubky_auth__no_identity"), description: t("pubky_auth__no_identity_desc")) return } - // State 2: Ring-authenticated (has session but no local secret key) guard let secretKey = try? Keychain.loadString(key: .pubkySecretKey), !secretKey.isEmpty else { @@ -801,14 +853,7 @@ extension AppViewModel { return } - // State 3: Bitkit-generated identity — can approve - do { - let request = try PubkyAuthRequest.parse(url: authUrl) - sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: authUrl, request: request)) - } catch { - Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") - toast(type: .error, title: t("pubky_auth__invalid_request")) - } + sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(request: request)) } private func handleNodeUri(_ url: String) { @@ -826,6 +871,11 @@ extension AppViewModel { contactPaymentContext?.id == context.id } + private func releaseContactPaymentContext(_ context: ContactPaymentContext) { + guard ownsContactPaymentContext(context) else { return } + contactPaymentContext = nil + } + func resetSendState(preservingContactPaymentContext: Bool = false) { scannedLightningInvoice = nil scannedOnchainInvoice = nil diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index c226c657e..3a4951b99 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -267,8 +267,8 @@ class SheetViewModel: ObservableObject { get { guard let config = activeSheetConfiguration, config.id == .pubkyAuthApproval else { return nil } let pubkyConfig = config.data as? PubkyAuthApprovalConfig - guard let authUrl = pubkyConfig?.authUrl, let request = pubkyConfig?.request else { return nil } - return PubkyAuthApprovalSheetItem(authUrl: authUrl, request: request) + guard let request = pubkyConfig?.request else { return nil } + return PubkyAuthApprovalSheetItem(request: request) } set { if newValue == nil { diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index c7842ee46..26098295c 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -32,14 +32,12 @@ func pubkyAuthDisplayPublicKey(_ publicKey: String?) -> String { } struct PubkyAuthApprovalConfig { - let authUrl: String let request: PubkyAuthRequest } struct PubkyAuthApprovalSheetItem: SheetItem { let id: SheetID = .pubkyAuthApproval let size: SheetSize = .large - let authUrl: String let request: PubkyAuthRequest } @@ -231,10 +229,14 @@ struct PubkyAuthApprovalSheet: View { descriptionText .padding(.bottom, 8) - BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) - .lineLimit(1) - .truncationMode(.tail) - .padding(.bottom, 32) + if !config.request.clientID.isEmpty { + BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) + .lineLimit(1) + .truncationMode(.tail) + .padding(.bottom, 32) + } else { + Spacer().frame(height: 24) + } permissionsSection @@ -379,6 +381,15 @@ struct PubkyAuthApprovalSheet: View { private func performAuthorization() async { guard state == .authorizing else { return } do { + if config.request.isRingSignup { + try await pubkyProfile.approveSignupAuth(request: config.request) + guard sheets.pubkyAuthApprovalSheetItem?.request.rawUrl == config.request.rawUrl else { + return + } + sheets.hideSheet() + return + } + guard let secretKey = try Keychain.loadString(key: .pubkySecretKey), !secretKey.isEmpty else { @@ -389,13 +400,18 @@ struct PubkyAuthApprovalSheet: View { try await PubkyService.approveAuthRequest( request: config.request, - authUrl: config.authUrl, + authUrl: config.request.rawUrl, accountName: watchOnlyAccountName, secretKeyHex: secretKey ) state = .success } catch { + if case PubkySignupError.alreadySignedIn = error { + app.toast(type: .info, title: t("pubky_auth__already_signed_in")) + sheets.hideSheet() + return + } Logger.error("Failed to approve pubky auth: \(error)", context: "PubkyAuthApprovalSheet") app.toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) state = .authorize diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index cb4cb80a2..de81be8b2 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -5,21 +5,50 @@ import XCTest final class PubkyAuthRequestTests: XCTestCase { private let relay = "https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" private let secret = "e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" - private let clientPublicKey = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + private let publicKey = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" func testProtocolUrlRecognizesPubkyAuthSchemeCaseInsensitively() { XCTAssertTrue(PubkyAuthRequest.isProtocolURL("pubkyauth://signin?caps=/pub/bitkit.to/:rw")) XCTAssertTrue(PubkyAuthRequest.isProtocolURL("PUBKYAUTH://signin?caps=/pub/bitkit.to/:rw")) XCTAssertTrue(PubkyAuthRequest.isProtocolURL(" pubkyauth://signin?caps=/pub/bitkit.to/:rw\n")) + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(ringSignupUrl())) XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example")) } + func testParseRingSignup() throws { + let request = try PubkyAuthRequest.parse(url: ringSignupUrl(signupToken: "invite code")) + + XCTAssertTrue(request.isRingSignup) + XCTAssertEqual(request.kind, .signUp) + XCTAssertEqual(request.homeserverPublicKey, publicKey) + XCTAssertEqual(request.signupToken, "invite code") + XCTAssertEqual(request.relay, "https://relay.example/inbox/") + XCTAssertEqual(request.capabilities, "/pub/example.app/:rw") + XCTAssertEqual( + request.authorizationUrl, + "pubkyauth:///?relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw" + ) + } + + func testParseRingSignupRejectsMissingOrDuplicateRequiredValues() { + let invalidUrls = [ + ringSignupUrl().replacingOccurrences(of: "&secret=\(secret)", with: ""), + "\(ringSignupUrl())&hs=other", + ] + + for url in invalidUrls { + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) + } + } + func testParseUrlPreservesRequestedCapabilities() throws { let capabilities = "/pub/bitkit.to/:rw" let url = authUrl(capabilities: capabilities) let request = try PubkyAuthRequest.parse(url: url) + XCTAssertFalse(request.isRingSignup) XCTAssertEqual(request.clientID, "paykit.test") XCTAssertEqual(request.capabilities, capabilities) XCTAssertEqual(request.permissions.count, 1) @@ -262,6 +291,15 @@ final class PubkyAuthRequestTests: XCTestCase { .map { "&\(PubkyAuthClaim.queryParameter)=\($0)" } .joined() return "pubkyauth://signin_grant?caps=\(capabilities)&relay=\(relay)&secret=\(secret)" + - "&cid=paykit.test&cpk=\(clientPublicKey)\(claims)" + "&cid=paykit.test&cpk=\(publicKey)\(claims)" + } + + private func ringSignupUrl(signupToken: String? = nil) -> String { + let token = signupToken.map { + "&st=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0)" + } ?? "" + return "pubkyring://signup?hs=\(publicKey)" + + "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw\(token)" } } diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index a3304d82f..fd4b1ae31 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -18,20 +18,39 @@ final class ShopPaymentRequestTests: XCTestCase { XCTAssertFalse(ShopPaymentRequest.isOnchainPayment(.lightning(invoice: lightningInvoice))) } - func testNonPaymentRequestDoesNotClearExistingPaymentState() async { + func testNonPaymentRequestsDoNotClearExistingPaymentState() async { let app = AppViewModel() + let requests = [ + "https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123", + pubkySignupUrl, + ] + + for request in requests { + app.scannedLightningInvoice = lightningInvoice + do { + try await app.handleScannedData(request, scope: .paymentRequests) + XCTFail("Expected the shop payment scope to reject a non-payment request") + } catch { + XCTAssertTrue(error is ScanHandlingError) + } + XCTAssertNotNil(app.scannedLightningInvoice) + } + } + + func testContactPaymentRejectsPubkySignupWithoutClearingSendState() async { + let app = AppViewModel() + let context = ContactPaymentContext(publicKey: "pubkycontact") + XCTAssertTrue(app.claimContactPaymentContext(context)) app.scannedLightningInvoice = lightningInvoice do { - try await app.handleScannedData( - "https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123", - scope: .paymentRequests - ) - XCTFail("Expected the shop payment scope to reject a setup request") + try await app.handleScannedData(pubkySignupUrl, claimedContactPaymentContext: context) + XCTFail("Expected contact payment to reject Pubky signup") } catch { XCTAssertTrue(error is ScanHandlingError) } + XCTAssertFalse(app.ownsContactPaymentContext(context)) XCTAssertNotNil(app.scannedLightningInvoice) } @@ -49,6 +68,13 @@ final class ShopPaymentRequestTests: XCTestCase { ) } + private var pubkySignupUrl: String { + "pubkyring://signup?hs=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + + "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + + "&caps=%2Fpub%2Fexample%2F%3Arw" + } + private var onchainInvoice: OnChainInvoice { OnChainInvoice( address: "bcrt1qexample", diff --git a/changelog.d/next/724.added.md b/changelog.d/next/724.added.md new file mode 100644 index 000000000..8aab2c4bb --- /dev/null +++ b/changelog.d/next/724.added.md @@ -0,0 +1 @@ +Added support for creating a Pubky identity from Pubky Ring signup requests. From 027f28abaf52ab451b012a1384697d191c9983f9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:35:05 -0500 Subject: [PATCH 2/9] fix: complete Pubky Ring signup --- Bitkit/MainNavView.swift | 33 ++++++++++++++++------- Bitkit/Managers/PubkyProfileManager.swift | 4 +-- Bitkit/Services/PubkyService.swift | 18 ++++++++++--- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 20808a331..91b9355cf 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -1,6 +1,12 @@ import SwiftUI struct MainNavView: View { + private enum PendingProfileSetupResumeState { + case inactive + case waiting + case ready + } + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @EnvironmentObject private var app: AppViewModel @@ -20,18 +26,23 @@ struct MainNavView: View { @State private var showClipboardAlert = false @State private var clipboardUri: String? + @State private var didResumePendingPubkyProfileSetup = false private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } - private var shouldResumePendingPubkyProfileSetup: Bool { - isPaykitUIActive && - pubkyProfile.isProfileSetupPending && - pubkyProfile.isAuthenticated && - sheets.activeSheetConfiguration == nil && - !sheets.isReplacingSheet && - navigation.currentRoute != .createProfile + private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { + guard pubkyProfile.isProfileSetupPending else { return .inactive } + guard isPaykitUIActive, + pubkyProfile.isAuthenticated, + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet, + navigation.currentRoute != .createProfile + else { + return .waiting + } + return .ready } // Delay constants for clipboard processing @@ -48,8 +59,12 @@ struct MainNavView: View { navigation.navigate(.spendingHwSigned) } } - .onChange(of: shouldResumePendingPubkyProfileSetup, initial: true) { _, shouldResume in - guard shouldResume else { return } + .onChange(of: pendingProfileSetupResumeState, initial: true) { _, resumeState in + if resumeState == .inactive { + didResumePendingPubkyProfileSetup = false + } + guard resumeState == .ready, !didResumePendingPubkyProfileSetup else { return } + didResumePendingPubkyProfileSetup = true navigation.navigate(.createProfile) } .sheet( diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index e9d1a602b..486e13dd6 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -368,14 +368,14 @@ class PubkyProfileManager: ObservableObject { throw PubkySignupError.alreadySignedIn } - try await PubkyService.registerIdentity( + let registeredSession = try await PubkyService.registerIdentity( secretKeyHex: secretKeyHex, homeserverZ32: homeserver, signupCode: request.signupToken ) try await PubkyService.approveRingAuth(authUrl: request.authorizationUrl, secretKeyHex: secretKeyHex) setProfileSetupPending(true) - _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + try await PubkyService.activateRegisteredIdentity(registeredSession) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) Self.notifyAppStateBackupChanged() diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 25a03733c..4096f7550 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -234,7 +234,7 @@ enum PubkyService { secretKeyHex: String, homeserverZ32: String, signupCode: String? = nil - ) async throws { + ) async throws -> PubkySessionBootstrapResult { try await PaykitSdkService.shared.registerIdentity( secretKeyHex: secretKeyHex, homeserverPublicKey: homeserverZ32, @@ -242,6 +242,10 @@ enum PubkyService { ) } + static func activateRegisteredIdentity(_ result: PubkySessionBootstrapResult) async throws { + try await PaykitSdkService.shared.activateRegisteredIdentity(result) + } + /// Sign in with an existing secret key. Returns new session secret. static func signIn(secretKeyHex: String) async throws -> String { let result = try await PaykitSdkService.shared.signIn(secretKeyHex: secretKeyHex) @@ -416,9 +420,9 @@ actor PaykitSdkService { secretKeyHex: String, homeserverPublicKey: String, signupCode: String? - ) async throws { + ) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { - _ = try await bootstrap().signUp( + try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), receiverNoiseSecretKey: sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey: homeserverPublicKey, @@ -428,6 +432,14 @@ actor PaykitSdkService { } } + func activateRegisteredIdentity(_ result: PubkySessionBootstrapResult) async throws { + try await operationLock.withLock { + let previousPublicKey = await currentSdkStatePublicKey() + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + markWalletBackupDataChanged() + } + } + func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() From 86e8068d8fe7cefaeaa29896eb5410455f6df5b0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:56:21 -0500 Subject: [PATCH 3/9] feat: support direct Pubky signup --- Bitkit/AppScene.swift | 10 +++- Bitkit/Managers/PubkyProfileManager.swift | 8 +-- Bitkit/Models/PubkyAuthRequest.swift | 54 ++++++++++++------- Bitkit/ViewModels/AppViewModel.swift | 32 ++++++++--- .../PubkyAuthApprovalSheet.swift | 2 +- BitkitTests/PubkyAuthRequestTests.swift | 26 ++++++++- BitkitTests/ShopPaymentRequestTests.swift | 5 ++ changelog.d/next/724.added.md | 2 +- 8 files changed, 105 insertions(+), 34 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 88067cd67..f0315347e 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -31,7 +31,7 @@ struct AppScene: View { @StateObject private var channelDetails = ChannelDetailsViewModel.shared @StateObject private var migrations = MigrationsService.shared @StateObject private var languageManager = LanguageManager.shared - @StateObject private var pubkyProfile = PubkyProfileManager() + @StateObject private var pubkyProfile: PubkyProfileManager @StateObject private var contactsManager = ContactsManager() @State private var keyboardManager = KeyboardManager() @State private var trezorManager: TrezorManager @@ -56,6 +56,7 @@ struct AppScene: View { init() { let sheetViewModel = SheetViewModel() let navigationViewModel = NavigationViewModel() + let pubkyProfile = PubkyProfileManager() let transferService = TransferService( lightningService: LightningService.shared, blocktankService: CoreService.shared.blocktank @@ -66,9 +67,14 @@ struct AppScene: View { PaykitFeatureFlags.enforceBuildAvailability() ContactPaymentsService.enableAllPaymentOptions() - _app = StateObject(wrappedValue: AppViewModel(sheetViewModel: sheetViewModel, navigationViewModel: navigationViewModel)) + _app = StateObject(wrappedValue: AppViewModel( + sheetViewModel: sheetViewModel, + navigationViewModel: navigationViewModel, + pubkyProfile: pubkyProfile + )) _sheets = StateObject(wrappedValue: sheetViewModel) _navigation = StateObject(wrappedValue: navigationViewModel) + _pubkyProfile = StateObject(wrappedValue: pubkyProfile) let feeEstimatesManager = FeeEstimatesManager() let walletVm = WalletViewModel( transferService: transferService, diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 486e13dd6..c3be6607f 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -354,9 +354,7 @@ class PubkyProfileManager: ObservableObject { } func approveSignupAuth(request: PubkyAuthRequest) async throws { - guard request.isRingSignup, - let homeserver = request.homeserverPublicKey - else { + guard request.isSignup, let homeserver = request.homeserverPublicKey else { throw PubkyServiceError.invalidAuthUrl } guard publicKey == nil, try !Self.hasStoredIdentity() else { @@ -373,7 +371,9 @@ class PubkyProfileManager: ObservableObject { homeserverZ32: homeserver, signupCode: request.signupToken ) - try await PubkyService.approveRingAuth(authUrl: request.authorizationUrl, secretKeyHex: secretKeyHex) + if let authorizationUrl = request.authorizationUrl { + try await PubkyService.approveRingAuth(authUrl: authorizationUrl, secretKeyHex: secretKeyHex) + } setProfileSetupPending(true) try await PubkyService.activateRegisteredIdentity(registeredSession) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index b41a94430..e6a90f441 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -62,11 +62,10 @@ struct PubkyAuthRequest { let bitkitClaim: PubkyAuthClaim? let homeserverPublicKey: String? let signupToken: String? - let authorizationUrl: String + let authorizationUrl: String? - var isRingSignup: Bool { - guard let components = URLComponents(string: rawUrl) else { return false } - return components.scheme?.lowercased() == "pubkyring" && components.host?.lowercased() == "signup" + var isSignup: Bool { + Self.isSignupURL(rawUrl) } static func isProtocolURL(_ value: String) -> Bool { @@ -85,11 +84,8 @@ struct PubkyAuthRequest { } static func parse(url: String) throws -> PubkyAuthRequest { - if let components = URLComponents(string: url), - components.scheme?.lowercased() == "pubkyring", - components.host?.lowercased() == "signup" - { - return try parseRingSignup(url: url, components: components) + if let components = URLComponents(string: url), isSignupURL(components) { + return try parseSignup(url: url, components: components) } let details = try Paykit.parsePubkyAuthUrl(authUrl: url) @@ -101,19 +97,41 @@ struct PubkyAuthRequest { relay: details.relayUrl, capabilities: capabilities, homeserverPublicKey: nil, - signupToken: nil + signupToken: nil, + authorizationUrl: url ) } - private static func parseRingSignup(url: String, components: URLComponents) throws -> PubkyAuthRequest { + static func isSignupURL(_ value: String) -> Bool { + guard let components = URLComponents(string: value) else { return false } + return isSignupURL(components) + } + + private static func isSignupURL(_ components: URLComponents) -> Bool { + switch components.scheme?.lowercased() { + case "pubkyring": + return components.host?.lowercased() == "signup" + case "pubkyauth": + return ["direct_signup", "signup"].contains(components.host?.lowercased()) + default: + return false + } + } + + private static func parseSignup(url: String, components: URLComponents) throws -> PubkyAuthRequest { let values = Dictionary(grouping: components.queryItems ?? [], by: \.name) - let relay = try requiredQueryValue("relay", from: values) - let secret = try requiredQueryValue("secret", from: values) - let capabilities = try requiredQueryValue("caps", from: values) let homeserver = try requiredQueryValue("hs", from: values) - let authorizationUrl = ringAuthorizationUrl(relay: relay, secret: secret, capabilities: capabilities) + let authorizesApp = components.scheme?.lowercased() == "pubkyring" + let relay = authorizesApp ? try requiredQueryValue("relay", from: values) : "" + let secret = authorizesApp ? try requiredQueryValue("secret", from: values) : "" + let capabilities = authorizesApp ? try requiredQueryValue("caps", from: values) : "" + let authorizationUrl = authorizesApp + ? ringAuthorizationUrl(relay: relay, secret: secret, capabilities: capabilities) + : nil do { - _ = try BitkitCore.parsePubkyAuthUrl(authUrl: authorizationUrl) + if let authorizationUrl { + _ = try BitkitCore.parsePubkyAuthUrl(authUrl: authorizationUrl) + } _ = try Paykit.normalizePubkyPublicKey(value: homeserver) } catch { throw PubkyAuthRequestError.invalidUrl @@ -142,7 +160,7 @@ struct PubkyAuthRequest { capabilities: String, homeserverPublicKey: String?, signupToken: String?, - authorizationUrl: String? = nil + authorizationUrl: String? ) throws -> PubkyAuthRequest { let permissions = parseCapabilities(capabilities) var seenServiceNames = Set() @@ -161,7 +179,7 @@ struct PubkyAuthRequest { bitkitClaim: bitkitClaim, homeserverPublicKey: homeserverPublicKey, signupToken: signupToken, - authorizationUrl: authorizationUrl ?? url + authorizationUrl: authorizationUrl ) } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index dd5d02fb4..1c091a41c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -119,6 +119,7 @@ class AppViewModel: ObservableObject { private let coreService: CoreService private let sheetViewModel: SheetViewModel private let navigationViewModel: NavigationViewModel + private let pubkyProfile: PubkyProfileManager private var scannedDataHandlingId: UUID? private var manualEntryValidationSequence: UInt64 = 0 @@ -130,12 +131,14 @@ class AppViewModel: ObservableObject { lightningService: LightningService = .shared, coreService: CoreService = .shared, sheetViewModel: SheetViewModel, - navigationViewModel: NavigationViewModel + navigationViewModel: NavigationViewModel, + pubkyProfile: PubkyProfileManager ) { self.lightningService = lightningService self.coreService = coreService self.sheetViewModel = sheetViewModel self.navigationViewModel = navigationViewModel + self.pubkyProfile = pubkyProfile setupManualEntryValidationDebounce() @@ -245,7 +248,11 @@ class AppViewModel: ObservableObject { /// Convenience initializer for previews and testing convenience init() { - self.init(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + self.init( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel(), + pubkyProfile: PubkyProfileManager() + ) } deinit {} @@ -505,7 +512,7 @@ extension AppViewModel { ) return } - handlePubkyAuthApproval(uri) + await handlePubkyAuthApproval(uri) return } @@ -683,7 +690,7 @@ extension AppViewModel { ) return } - handlePubkyAuthApproval(authUrl) + await handlePubkyAuthApproval(authUrl) case let .gift(code, amount): sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount))) default: @@ -810,7 +817,7 @@ extension AppViewModel { sheetViewModel.showSheet(.lnurlAuth, data: LnurlAuthConfig(lnurl: lnurl, authData: data)) } - private func handlePubkyAuthApproval(_ authUrl: String) { + private func handlePubkyAuthApproval(_ authUrl: String) async { let request: PubkyAuthRequest do { @@ -821,7 +828,7 @@ extension AppViewModel { return } - if request.isRingSignup { + if request.isSignup { do { guard try !PubkyProfileManager.hasStoredIdentity() else { toast(type: .info, title: t("pubky_auth__already_signed_in")) @@ -833,6 +840,19 @@ extension AppViewModel { return } + if request.authorizationUrl == nil { + sheetViewModel.hideSheet() + do { + try await pubkyProfile.approveSignupAuth(request: request) + } catch PubkySignupError.alreadySignedIn { + toast(type: .info, title: t("pubky_auth__already_signed_in")) + } catch { + Logger.error("Failed to complete direct Pubky signup: \(error)", context: "AppViewModel") + toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) + } + return + } + sheetViewModel.showSheet( .pubkyAuthApproval, data: PubkyAuthApprovalConfig(request: request) diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 26098295c..2dc3749ef 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -381,7 +381,7 @@ struct PubkyAuthApprovalSheet: View { private func performAuthorization() async { guard state == .authorizing else { return } do { - if config.request.isRingSignup { + if config.request.isSignup { try await pubkyProfile.approveSignupAuth(request: config.request) guard sheets.pubkyAuthApprovalSheetItem?.request.rawUrl == config.request.rawUrl else { return diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index de81be8b2..4e7b48a8a 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -12,13 +12,14 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertTrue(PubkyAuthRequest.isProtocolURL("PUBKYAUTH://signin?caps=/pub/bitkit.to/:rw")) XCTAssertTrue(PubkyAuthRequest.isProtocolURL(" pubkyauth://signin?caps=/pub/bitkit.to/:rw\n")) XCTAssertTrue(PubkyAuthRequest.isProtocolURL(ringSignupUrl())) + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(directSignupUrl(action: "direct_signup"))) XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example")) } func testParseRingSignup() throws { let request = try PubkyAuthRequest.parse(url: ringSignupUrl(signupToken: "invite code")) - XCTAssertTrue(request.isRingSignup) + XCTAssertTrue(request.isSignup) XCTAssertEqual(request.kind, .signUp) XCTAssertEqual(request.homeserverPublicKey, publicKey) XCTAssertEqual(request.signupToken, "invite code") @@ -31,6 +32,20 @@ final class PubkyAuthRequestTests: XCTestCase { ) } + func testParseDirectSignupAcceptsCanonicalAndLegacyFormats() throws { + for action in ["direct_signup", "signup"] { + let request = try PubkyAuthRequest.parse(url: directSignupUrl(action: action, signupToken: "invite code")) + + XCTAssertTrue(request.isSignup) + XCTAssertEqual(request.kind, .signUp) + XCTAssertEqual(request.homeserverPublicKey, publicKey) + XCTAssertEqual(request.signupToken, "invite code") + XCTAssertEqual(request.relay, "") + XCTAssertEqual(request.capabilities, "") + XCTAssertNil(request.authorizationUrl) + } + } + func testParseRingSignupRejectsMissingOrDuplicateRequiredValues() { let invalidUrls = [ ringSignupUrl().replacingOccurrences(of: "&secret=\(secret)", with: ""), @@ -48,7 +63,7 @@ final class PubkyAuthRequestTests: XCTestCase { let request = try PubkyAuthRequest.parse(url: url) - XCTAssertFalse(request.isRingSignup) + XCTAssertFalse(request.isSignup) XCTAssertEqual(request.clientID, "paykit.test") XCTAssertEqual(request.capabilities, capabilities) XCTAssertEqual(request.permissions.count, 1) @@ -302,4 +317,11 @@ final class PubkyAuthRequestTests: XCTestCase { "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw\(token)" } + + private func directSignupUrl(action: String, signupToken: String? = nil) -> String { + let token = signupToken.map { + "&st=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0)" + } ?? "" + return "pubkyauth://\(action)?hs=\(publicKey)\(token)" + } } diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index fd4b1ae31..07738e6a7 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -23,6 +23,7 @@ final class ShopPaymentRequestTests: XCTestCase { let requests = [ "https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123", pubkySignupUrl, + directPubkySignupUrl, ] for request in requests { @@ -75,6 +76,10 @@ final class ShopPaymentRequestTests: XCTestCase { "&caps=%2Fpub%2Fexample%2F%3Arw" } + private var directPubkySignupUrl: String { + "pubkyauth://direct_signup?hs=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo&st=invite" + } + private var onchainInvoice: OnChainInvoice { OnChainInvoice( address: "bcrt1qexample", diff --git a/changelog.d/next/724.added.md b/changelog.d/next/724.added.md index 8aab2c4bb..fe1fcd278 100644 --- a/changelog.d/next/724.added.md +++ b/changelog.d/next/724.added.md @@ -1 +1 @@ -Added support for creating a Pubky identity from Pubky Ring signup requests. +Added support for creating a Pubky identity from app-authorized and direct Pubky signup requests. From 00d8cae81b5b298c50beb8af02c30d3323e00aaf Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 07:46:38 -0500 Subject: [PATCH 4/9] fix: complete Pubky signup handoff --- Bitkit/MainNavView.swift | 13 +++++++++ Bitkit/Models/PubkyAuthRequest.swift | 7 ++++- Bitkit/ViewModels/AppViewModel.swift | 8 ++++++ BitkitTests/PubkyAuthRequestTests.swift | 35 ++++++++++++++----------- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 91b9355cf..ab5508d54 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -422,6 +422,19 @@ struct MainNavView: View { } message: { Text(t("other__clipboard_redirect_msg")) } + .overlay { + if app.isCompletingPubkySignup { + ZStack { + Color.black.ignoresSafeArea() + + HStack(spacing: 12) { + ActivityIndicator(size: 20) + BodyMText(t("profile__deriving_keys"), textColor: .white64) + } + } + .accessibilityIdentifier("PubkySignupLoading") + } + } } // MARK: - Loading View diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index e6a90f441..2758c7bf8 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -121,7 +121,12 @@ struct PubkyAuthRequest { private static func parseSignup(url: String, components: URLComponents) throws -> PubkyAuthRequest { let values = Dictionary(grouping: components.queryItems ?? [], by: \.name) let homeserver = try requiredQueryValue("hs", from: values) - let authorizesApp = components.scheme?.lowercased() == "pubkyring" + let authorizesApp = components.scheme?.lowercased() == "pubkyring" || + ( + components.scheme?.lowercased() == "pubkyauth" && + components.host?.lowercased() == "signup" && + ["relay", "secret", "caps"].contains { values[$0] != nil } + ) let relay = authorizesApp ? try requiredQueryValue("relay", from: values) : "" let secret = authorizesApp ? try requiredQueryValue("secret", from: values) : "" let capabilities = authorizesApp ? try requiredQueryValue("caps", from: values) : "" diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1c091a41c..f146861c8 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -66,6 +66,7 @@ class AppViewModel: ObservableObject { // LNURL @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? + @Published private(set) var isCompletingPubkySignup = false // Onboarding @AppStorage("hasDismissedWidgetsOnboardingHint") var hasDismissedWidgetsOnboardingHint: Bool = false @@ -824,6 +825,7 @@ extension AppViewModel { request = try PubkyAuthRequest.parse(url: authUrl) } catch { Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") + sheetViewModel.hideSheetIfActive(.scanner, reason: "Invalid Pubky auth request") toast(type: .error, title: t("pubky_auth__invalid_request")) return } @@ -831,17 +833,21 @@ extension AppViewModel { if request.isSignup { do { guard try !PubkyProfileManager.hasStoredIdentity() else { + sheetViewModel.hideSheetIfActive(.scanner, reason: "Pubky identity already exists") toast(type: .info, title: t("pubky_auth__already_signed_in")) return } } catch { Logger.error("Failed to read stored Pubky identity: \(error)", context: "AppViewModel") + sheetViewModel.hideSheetIfActive(.scanner, reason: "Pubky identity check failed") toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) return } if request.authorizationUrl == nil { sheetViewModel.hideSheet() + isCompletingPubkySignup = true + defer { isCompletingPubkySignup = false } do { try await pubkyProfile.approveSignupAuth(request: request) } catch PubkySignupError.alreadySignedIn { @@ -862,6 +868,7 @@ extension AppViewModel { let hasSession = (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false guard hasSession else { + sheetViewModel.hideSheetIfActive(.scanner, reason: "Pubky identity is missing") toast(type: .warning, title: t("pubky_auth__no_identity"), description: t("pubky_auth__no_identity_desc")) return } @@ -869,6 +876,7 @@ extension AppViewModel { guard let secretKey = try? Keychain.loadString(key: .pubkySecretKey), !secretKey.isEmpty else { + sheetViewModel.hideSheetIfActive(.scanner, reason: "Pubky identity requires Ring") toast(type: .info, title: t("pubky_auth__use_ring"), description: t("pubky_auth__use_ring_desc")) return } diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 4e7b48a8a..11fab1bb8 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -16,20 +16,22 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example")) } - func testParseRingSignup() throws { - let request = try PubkyAuthRequest.parse(url: ringSignupUrl(signupToken: "invite code")) - - XCTAssertTrue(request.isSignup) - XCTAssertEqual(request.kind, .signUp) - XCTAssertEqual(request.homeserverPublicKey, publicKey) - XCTAssertEqual(request.signupToken, "invite code") - XCTAssertEqual(request.relay, "https://relay.example/inbox/") - XCTAssertEqual(request.capabilities, "/pub/example.app/:rw") - XCTAssertEqual( - request.authorizationUrl, - "pubkyauth:///?relay=https%3A%2F%2Frelay.example%2Finbox%2F" + - "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw" - ) + func testParseAuthorizedSignup() throws { + for scheme in ["pubkyring", "pubkyauth"] { + let request = try PubkyAuthRequest.parse(url: ringSignupUrl(signupToken: "invite code", scheme: scheme)) + + XCTAssertTrue(request.isSignup) + XCTAssertEqual(request.kind, .signUp) + XCTAssertEqual(request.homeserverPublicKey, publicKey) + XCTAssertEqual(request.signupToken, "invite code") + XCTAssertEqual(request.relay, "https://relay.example/inbox/") + XCTAssertEqual(request.capabilities, "/pub/example.app/:rw") + XCTAssertEqual( + request.authorizationUrl, + "pubkyauth:///?relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw" + ) + } } func testParseDirectSignupAcceptsCanonicalAndLegacyFormats() throws { @@ -50,6 +52,7 @@ final class PubkyAuthRequestTests: XCTestCase { let invalidUrls = [ ringSignupUrl().replacingOccurrences(of: "&secret=\(secret)", with: ""), "\(ringSignupUrl())&hs=other", + directSignupUrl(action: "signup") + "&relay=https%3A%2F%2Frelay.example", ] for url in invalidUrls { @@ -309,11 +312,11 @@ final class PubkyAuthRequestTests: XCTestCase { "&cid=paykit.test&cpk=\(publicKey)\(claims)" } - private func ringSignupUrl(signupToken: String? = nil) -> String { + private func ringSignupUrl(signupToken: String? = nil, scheme: String = "pubkyring") -> String { let token = signupToken.map { "&st=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0)" } ?? "" - return "pubkyring://signup?hs=\(publicKey)" + + return "\(scheme)://signup?hs=\(publicKey)" + "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw\(token)" } From 1dcc1cef7e2b84eefc69f5a3bd0616a00d718c51 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 17:12:10 -0500 Subject: [PATCH 5/9] fix: recover failed pubky signup --- Bitkit/Managers/PubkyProfileManager.swift | 11 ++++++++--- Bitkit/Services/PubkyService.swift | 10 +++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index c3be6607f..962885777 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -374,13 +374,18 @@ class PubkyProfileManager: ObservableObject { if let authorizationUrl = request.authorizationUrl { try await PubkyService.approveRingAuth(authUrl: authorizationUrl, secretKeyHex: secretKeyHex) } - setProfileSetupPending(true) - try await PubkyService.activateRegisteredIdentity(registeredSession) + do { + try await PubkyService.activateRegisteredIdentity(registeredSession) + } catch { + setProfileSetupPending(false) + throw error + } UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - Self.notifyAppStateBackupChanged() self.publicKey = publicKey authState = .authenticated + setProfileSetupPending(true) + Self.notifyAppStateBackupChanged() } func saveProfile( diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 4096f7550..d1524862f 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -435,7 +435,15 @@ actor PaykitSdkService { func activateRegisteredIdentity(_ result: PubkySessionBootstrapResult) async throws { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() - try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + do { + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + } catch { + try? sessionProvider.clearSessionAccess() + try? Keychain.delete(key: .paykitSdkState) + resetRuntime() + markWalletBackupDataChanged() + throw error + } markWalletBackupDataChanged() } } From f1a6adfd86f217d300cbd3b979e50243f7f9f9d3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 6 Sep 2026 17:01:15 +0200 Subject: [PATCH 6/9] fix: recover and verify pubky signup state --- .../PubkyKeyDerivationLoadingView.swift | 11 +++ Bitkit/MainNavView.swift | 5 +- Bitkit/Managers/PubkyProfileManager.swift | 61 +++++++++++---- Bitkit/Views/Profile/CreateProfileView.swift | 8 +- BitkitTests/PubkyProfileManagerTests.swift | 78 +++++++++++++++++++ 5 files changed, 135 insertions(+), 28 deletions(-) create mode 100644 Bitkit/Components/PubkyKeyDerivationLoadingView.swift diff --git a/Bitkit/Components/PubkyKeyDerivationLoadingView.swift b/Bitkit/Components/PubkyKeyDerivationLoadingView.swift new file mode 100644 index 000000000..7d60323d2 --- /dev/null +++ b/Bitkit/Components/PubkyKeyDerivationLoadingView.swift @@ -0,0 +1,11 @@ +import SwiftUI + +struct PubkyKeyDerivationLoadingView: View { + var body: some View { + VStack(spacing: 12) { + ActivityIndicator(size: 32) + BodyMText(t("profile__deriving_keys"), textColor: .white64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index ab5508d54..32ee9da05 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -427,10 +427,7 @@ struct MainNavView: View { ZStack { Color.black.ignoresSafeArea() - HStack(spacing: 12) { - ActivityIndicator(size: 20) - BodyMText(t("profile__deriving_keys"), textColor: .white64) - } + PubkyKeyDerivationLoadingView() } .accessibilityIdentifier("PubkySignupLoading") } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 962885777..b9b51407e 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1,4 +1,5 @@ import Foundation +import struct Paykit.PubkySessionBootstrapResult import SwiftUI enum PubkyAuthState: Equatable { @@ -258,10 +259,7 @@ class PubkyProfileManager: ObservableObject { existingImageUrl: String? = nil, avatarImage: UIImage? = nil ) async throws { - if isProfileSetupPending { - guard let publicKey else { - throw PubkyServiceError.sessionNotActive - } + if isProfileSetupPending, let publicKey { try await createProfile( publicKey: publicKey, name: name, @@ -274,6 +272,7 @@ class PubkyProfileManager: ObservableObject { return } + setProfileSetupPending(false) let (publicKeyZ32, secretKeyHex) = try await deriveKeys() _ = try await Task.detached { @@ -366,20 +365,34 @@ class PubkyProfileManager: ObservableObject { throw PubkySignupError.alreadySignedIn } - let registeredSession = try await PubkyService.registerIdentity( - secretKeyHex: secretKeyHex, - homeserverZ32: homeserver, - signupCode: request.signupToken + try await completeSignupAuthentication( + publicKey: publicKey, + registerIdentity: { + try await PubkyService.registerIdentity( + secretKeyHex: secretKeyHex, + homeserverZ32: homeserver, + signupCode: request.signupToken + ) + }, + approveAuth: { + if let authorizationUrl = request.authorizationUrl { + try await PubkyService.approveRingAuth(authUrl: authorizationUrl, secretKeyHex: secretKeyHex) + } + }, + activateIdentity: { try await PubkyService.activateRegisteredIdentity($0) } ) - if let authorizationUrl = request.authorizationUrl { - try await PubkyService.approveRingAuth(authUrl: authorizationUrl, secretKeyHex: secretKeyHex) - } - do { - try await PubkyService.activateRegisteredIdentity(registeredSession) - } catch { - setProfileSetupPending(false) - throw error - } + } + + private func completeSignupAuthentication( + publicKey: String, + registerIdentity: () async throws -> PubkySessionBootstrapResult, + approveAuth: () async throws -> Void, + activateIdentity: (PubkySessionBootstrapResult) async throws -> Void + ) async throws { + setProfileSetupPending(false) + let registeredSession = try await registerIdentity() + try await approveAuth() + try await activateIdentity(registeredSession) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) self.publicKey = publicKey @@ -719,6 +732,20 @@ class PubkyProfileManager: ObservableObject { } #if DEBUG + func completeSignupAuthenticationForTesting( + publicKey: String, + registerIdentity: () async throws -> PubkySessionBootstrapResult, + approveAuth: () async throws -> Void, + activateIdentity: (PubkySessionBootstrapResult) async throws -> Void + ) async throws { + try await completeSignupAuthentication( + publicKey: publicKey, + registerIdentity: registerIdentity, + approveAuth: approveAuth, + activateIdentity: activateIdentity + ) + } + func setActiveAuthAttemptIDForTesting(_ attemptID: UUID?) { activeAuthAttemptID = attemptID } diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index a0a848db9..9b3e84cf4 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -145,13 +145,7 @@ struct CreateProfileView: View { // MARK: - Loading private var loadingView: some View { - VStack(spacing: 12) { - Spacer() - ActivityIndicator(size: 32) - BodyMText(t("profile__deriving_keys"), textColor: .white64) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + PubkyKeyDerivationLoadingView() } // MARK: - Image Selection diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 77ebf71fc..4ebc8d6b5 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1,7 +1,75 @@ @testable import Bitkit +import class Paykit.PubkySessionAccess +import struct Paykit.PubkySessionBootstrapResult import XCTest final class PubkyProfileManagerTests: XCTestCase { + @MainActor + func testCreateIdentityRecoversStalePendingSetupWithoutPublicKey() async { + let defaults = UserDefaults.standard + let previousPending = defaults.object(forKey: "pubky_profile_setup_pending") + defer { defaults.set(previousPending, forKey: "pubky_profile_setup_pending") } + defaults.set(true, forKey: "pubky_profile_setup_pending") + let manager = KeyDerivationProbeProfileManager() + + do { + try await manager.createIdentity(name: "Test", bio: "", links: []) + XCTFail("Expected key derivation probe to stop creation") + } catch { + XCTAssertTrue(manager.didDeriveKeys) + XCTAssertFalse(manager.isProfileSetupPending) + } + } + + @MainActor + func testSignupFinishesProfileSetupOnlyAfterActivation() async throws { + let defaults = UserDefaults.standard + let previousPending = defaults.object(forKey: "pubky_profile_setup_pending") + let previousSharing = defaults.object(forKey: PrivatePaykitService.publishingEnabledKey) + defer { + defaults.set(previousPending, forKey: "pubky_profile_setup_pending") + defaults.set(previousSharing, forKey: PrivatePaykitService.publishingEnabledKey) + } + + for failingStep in [nil, "register", "authorize", "activate"] { + defaults.set(true, forKey: "pubky_profile_setup_pending") + let manager = PubkyProfileManager() + let session = PubkySessionBootstrapResult(sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test") + var events: [String] = [] + func perform(_ step: String) throws { + XCTAssertFalse(manager.isProfileSetupPending) + XCTAssertNil(manager.publicKey) + events.append(step) + if step == failingStep { throw PubkyServiceError.authFailed(step) } + } + + do { + try await manager.completeSignupAuthenticationForTesting( + publicKey: "pubky_test", + registerIdentity: { + try perform("register") + return session + }, + approveAuth: { try perform("authorize") }, + activateIdentity: { + XCTAssertTrue($0.sessionAccess === session.sessionAccess) + try perform("activate") + } + ) + XCTAssertNil(failingStep) + XCTAssertEqual(events, ["register", "authorize", "activate"]) + XCTAssertTrue(manager.isProfileSetupPending) + XCTAssertEqual(manager.publicKey, "pubky_test") + XCTAssertEqual(manager.authState, .authenticated) + } catch { + XCTAssertEqual(events.last, failingStep) + XCTAssertFalse(manager.isProfileSetupPending) + XCTAssertNil(manager.publicKey) + XCTAssertEqual(manager.authState, .idle) + } + } + } + // MARK: - Ring callbacks func testPubkyRingAuthURLBuilderAddsXCallbackParams() throws { @@ -861,6 +929,16 @@ final class PubkyProfileManagerTests: XCTestCase { } } +@MainActor +private class KeyDerivationProbeProfileManager: PubkyProfileManager { + var didDeriveKeys = false + + override func deriveKeys() async throws -> (String, String) { + didDeriveKeys = true + throw PubkyServiceError.authFailed("key derivation probe") + } +} + private func XCTAssertThrowsErrorAsync( _ expression: () async throws -> some Any, file: StaticString = #filePath, From 9320e11b8a43d1a724133c27ea2f35800f380408 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 20:14:06 +0300 Subject: [PATCH 7/9] fix: require consent for every pubky signup --- .../PubkyKeyDerivationLoadingView.swift | 11 ------- Bitkit/MainNavView.swift | 10 ------ .../Localization/en.lproj/Localizable.strings | 2 ++ Bitkit/ViewModels/AppViewModel.swift | 16 ---------- Bitkit/Views/Profile/CreateProfileView.swift | 6 +++- .../PubkyAuthApprovalSheet.swift | 32 ++++++++++++++++--- BitkitTests/ShopPaymentRequestTests.swift | 25 +++++++++++++++ 7 files changed, 60 insertions(+), 42 deletions(-) delete mode 100644 Bitkit/Components/PubkyKeyDerivationLoadingView.swift diff --git a/Bitkit/Components/PubkyKeyDerivationLoadingView.swift b/Bitkit/Components/PubkyKeyDerivationLoadingView.swift deleted file mode 100644 index 7d60323d2..000000000 --- a/Bitkit/Components/PubkyKeyDerivationLoadingView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct PubkyKeyDerivationLoadingView: View { - var body: some View { - VStack(spacing: 12) { - ActivityIndicator(size: 32) - BodyMText(t("profile__deriving_keys"), textColor: .white64) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 32ee9da05..91b9355cf 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -422,16 +422,6 @@ struct MainNavView: View { } message: { Text(t("other__clipboard_redirect_msg")) } - .overlay { - if app.isCompletingPubkySignup { - ZStack { - Color.black.ignoresSafeArea() - - PubkyKeyDerivationLoadingView() - } - .accessibilityIdentifier("PubkySignupLoading") - } - } } // MARK: - Loading View diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 889472e6c..8871ecc61 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -716,6 +716,8 @@ "pubky_auth__use_ring" = "Use Pubky Ring"; "pubky_auth__use_ring_desc" = "Your identity was created with Pubky Ring. Open Ring to approve this request."; "pubky_auth__invalid_request" = "Invalid auth request"; +"pubky_auth__homeserver" = "Homeserver"; +"pubky_auth__signup_description" = "Create a new Pubky identity on this homeserver. Only continue if you trust it."; "pubky_auth__approval_failed" = "Authorization Failed"; "watch_only_accounts__active_section" = "Active accounts"; "watch_only_accounts__copy_xpub" = "Copy xpub"; diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f146861c8..fd2a86189 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -66,7 +66,6 @@ class AppViewModel: ObservableObject { // LNURL @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? - @Published private(set) var isCompletingPubkySignup = false // Onboarding @AppStorage("hasDismissedWidgetsOnboardingHint") var hasDismissedWidgetsOnboardingHint: Bool = false @@ -844,21 +843,6 @@ extension AppViewModel { return } - if request.authorizationUrl == nil { - sheetViewModel.hideSheet() - isCompletingPubkySignup = true - defer { isCompletingPubkySignup = false } - do { - try await pubkyProfile.approveSignupAuth(request: request) - } catch PubkySignupError.alreadySignedIn { - toast(type: .info, title: t("pubky_auth__already_signed_in")) - } catch { - Logger.error("Failed to complete direct Pubky signup: \(error)", context: "AppViewModel") - toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) - } - return - } - sheetViewModel.showSheet( .pubkyAuthApproval, data: PubkyAuthApprovalConfig(request: request) diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index 9b3e84cf4..3081f1c7a 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -145,7 +145,11 @@ struct CreateProfileView: View { // MARK: - Loading private var loadingView: some View { - PubkyKeyDerivationLoadingView() + VStack(spacing: 12) { + ActivityIndicator(size: 32) + BodyMText(t("profile__deriving_keys"), textColor: .white64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Image Selection diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 2dc3749ef..17c6179c4 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -226,8 +226,15 @@ struct PubkyAuthApprovalSheet: View { GeometryReader { geometry in ScrollView { VStack(alignment: .leading, spacing: 0) { - descriptionText - .padding(.bottom, 8) + if config.request.isSignup { + BodyMText(t("pubky_auth__signup_description")) + .padding(.bottom, 16) + } + + if !config.request.permissions.isEmpty { + descriptionText + .padding(.bottom, 8) + } if !config.request.clientID.isEmpty { BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) @@ -238,15 +245,32 @@ struct PubkyAuthApprovalSheet: View { Spacer().frame(height: 24) } - permissionsSection + if !config.request.permissions.isEmpty { + permissionsSection + } Spacer(minLength: 32) trustWarning .padding(.bottom, 16) - profileCard + if let homeserver = config.request.homeserverPublicKey { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("pubky_auth__homeserver"), textColor: .white64) + BodyMSBText(homeserver) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(24) + .background(Color.gray6) + .cornerRadius(16) .padding(.bottom, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("PubkySignupHomeserver") + } else { + profileCard + .padding(.bottom, 16) + } } .frame(minHeight: geometry.size.height, alignment: .top) } diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index 07738e6a7..f1f705ec2 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -55,6 +55,31 @@ final class ShopPaymentRequestTests: XCTestCase { XCTAssertNotNil(app.scannedLightningInvoice) } + func testSignupScannerRoutesRequireApproval() async throws { + let defaults = UserDefaults.standard + let previousEnabled = defaults.object(forKey: PaykitFeatureFlags.uiEnabledKey) + defer { defaults.set(previousEnabled, forKey: PaykitFeatureFlags.uiEnabledKey) } + defaults.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + XCTAssertFalse(try PubkyProfileManager.hasStoredIdentity()) + + for url in [pubkySignupUrl, directPubkySignupUrl, directPubkySignupUrl.replacingOccurrences(of: "direct_signup", with: "signup")] { + let sheets = SheetViewModel() + let app = AppViewModel( + sheetViewModel: sheets, + navigationViewModel: NavigationViewModel(), + pubkyProfile: PubkyProfileManager() + ) + + try await app.handleScannedData(url) + + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .pubkyAuthApproval) + let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? PubkyAuthApprovalConfig) + XCTAssertTrue(config.request.isSignup) + XCTAssertEqual(config.request.homeserverPublicKey, "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo") + XCTAssertFalse(try PubkyProfileManager.hasStoredIdentity()) + } + } + private var lightningInvoice: LightningInvoice { LightningInvoice( bolt11: "test-invoice", From 951c10267c4bb1c5f21dbf47d692bc5b775b4ad1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 21:00:10 +0300 Subject: [PATCH 8/9] fix: preserve pubky homeserver on restore --- Bitkit/Managers/PubkyProfileManager.swift | 103 +++++++++++++-------- BitkitTests/PubkyProfileManagerTests.swift | 91 +++++++++++++++++- 2 files changed, 155 insertions(+), 39 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index b9b51407e..6f507d27d 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -257,7 +257,10 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String] = [], existingImageUrl: String? = nil, - avatarImage: UIImage? = nil + avatarImage: UIImage? = nil, + loadStoredSecretKey: () async throws -> String? = { + try await Task.detached { try Keychain.loadString(key: .pubkySecretKey) }.value + } ) async throws { if isProfileSetupPending, let publicKey { try await createProfile( @@ -273,49 +276,73 @@ class PubkyProfileManager: ObservableObject { } setProfileSetupPending(false) - let (publicKeyZ32, secretKeyHex) = try await deriveKeys() - - _ = try await Task.detached { - let signupDetails: (homeserverPubky: String, signupCode: String?) - if let homeserverPubky = Env.e2eHomeserverPubky { - signupDetails = (homeserverPubky, nil) - } else { - let homegate = try await Self.fetchHomegateSignupCode() - signupDetails = (homegate.homeserverPubky, homegate.signupCode) - } - - var session: String - do { - session = try await PubkyService.signUp( - secretKeyHex: secretKeyHex, - homeserverZ32: signupDetails.homeserverPubky, - signupCode: signupDetails.signupCode + try await Self.completeIdentityCreation( + loadStoredSecretKey: loadStoredSecretKey, + signIn: { secretKeyHex in + try await Task.detached { + _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + return try Self.publicKeyFromSecretKey(secretKeyHex) + }.value + }, + signUp: { + let (publicKey, secretKeyHex) = try await self.deriveKeys() + _ = try await Task.detached { + let signupDetails: (homeserverPubky: String, signupCode: String?) + if let homeserverPubky = Env.e2eHomeserverPubky { + signupDetails = (homeserverPubky, nil) + } else { + let homegate = try await Self.fetchHomegateSignupCode() + signupDetails = (homegate.homeserverPubky, homegate.signupCode) + } + + do { + return try await PubkyService.signUp( + secretKeyHex: secretKeyHex, + homeserverZ32: signupDetails.homeserverPubky, + signupCode: signupDetails.signupCode + ) + } catch { + Logger.info("signUp failed (likely already registered), trying signIn: \(error)", context: "PubkyProfileManager") + return try await PubkyService.signIn(secretKeyHex: secretKeyHex) + } + }.value + return publicKey + }, + createProfile: { publicKey in + try await self.createProfile( + publicKey: publicKey, + name: name, + bio: bio, + links: links, + tags: tags, + existingImageUrl: existingImageUrl, + avatarImage: avatarImage ) - } catch { - Logger.info("signUp failed (likely already registered), trying signIn: \(error)", context: "PubkyProfileManager") - session = try await PubkyService.signIn(secretKeyHex: secretKeyHex) - } + }, + discardSessionAccess: { await self.discardAbandonedSession() } + ) + } - return session - }.value + static func completeIdentityCreation( + loadStoredSecretKey: () async throws -> String?, + signIn: (String) async throws -> String, + signUp: () async throws -> String, + createProfile: (String) async throws -> Void, + discardSessionAccess: () async -> Void + ) async throws { + if let secretKeyHex = try await loadStoredSecretKey(), !secretKeyHex.isEmpty { + let publicKey = try await signIn(secretKeyHex) + try await createProfile(publicKey) + return + } + let publicKey = try await signUp() do { - try await createProfile( - publicKey: publicKeyZ32, - name: name, - bio: bio, - links: links, - tags: tags, - existingImageUrl: existingImageUrl, - avatarImage: avatarImage - ) + try await createProfile(publicKey) } catch { - let profileCreationError = error - await discardAbandonedSession() - throw profileCreationError + await discardSessionAccess() + throw error } - - Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager") } private func createProfile( diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 4ebc8d6b5..6727ac3cd 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -4,6 +4,95 @@ import struct Paykit.PubkySessionBootstrapResult import XCTest final class PubkyProfileManagerTests: XCTestCase { + @MainActor + func testIdentityRestorationPreservesCredentialsForRetry() async throws { + for failedStep in ["load", "signIn", "profile"] { + for failure in [PubkyServiceError.authFailed("offline") as Error, CancellationError()] { + var storedKey: String? = "existing-key" + var shouldFail = true + var profilePublicKey: String? + + func complete() async throws { + try await PubkyProfileManager.completeIdentityCreation( + loadStoredSecretKey: { + if shouldFail, failedStep == "load" { throw failure } + return storedKey + }, + signIn: { + XCTAssertEqual($0, "existing-key") + if shouldFail, failedStep == "signIn" { throw failure } + return "pubky_existing" + }, + signUp: { + XCTFail("An existing identity must not be registered on another homeserver") + return "pubky_new" + }, + createProfile: { + if shouldFail, failedStep == "profile" { throw failure } + profilePublicKey = $0 + }, + discardSessionAccess: { + storedKey = nil + XCTFail("Recovery must preserve the existing identity") + } + ) + } + + do { + try await complete() + XCTFail("Expected recovery to fail") + } catch { + XCTAssertEqual(error is CancellationError, failure is CancellationError) + XCTAssertEqual(error.localizedDescription, failure.localizedDescription) + } + XCTAssertNil(profilePublicKey) + XCTAssertEqual(storedKey, "existing-key") + + shouldFail = false + try await complete() + XCTAssertEqual(profilePublicKey, "pubky_existing") + XCTAssertEqual(storedKey, "existing-key") + } + } + } + + @MainActor + func testIdentityCreationWithoutLocalKeyKeepsSignupAndCleanup() async throws { + for storedKey in [nil, ""] as [String?] { + for failsToSaveProfile in [false, true] { + var didSignUp = false + var didDiscard = false + var profilePublicKey: String? + + do { + try await PubkyProfileManager.completeIdentityCreation( + loadStoredSecretKey: { storedKey }, + signIn: { _ in + XCTFail("No local identity exists to restore") + return "pubky_existing" + }, + signUp: { + didSignUp = true + return "pubky_new" + }, + createProfile: { + if failsToSaveProfile { throw PubkyServiceError.authFailed("profile") } + profilePublicKey = $0 + }, + discardSessionAccess: { didDiscard = true } + ) + XCTAssertFalse(failsToSaveProfile) + } catch { + XCTAssertTrue(failsToSaveProfile) + } + + XCTAssertTrue(didSignUp) + XCTAssertEqual(didDiscard, failsToSaveProfile) + XCTAssertEqual(profilePublicKey, failsToSaveProfile ? nil : "pubky_new") + } + } + } + @MainActor func testCreateIdentityRecoversStalePendingSetupWithoutPublicKey() async { let defaults = UserDefaults.standard @@ -13,7 +102,7 @@ final class PubkyProfileManagerTests: XCTestCase { let manager = KeyDerivationProbeProfileManager() do { - try await manager.createIdentity(name: "Test", bio: "", links: []) + try await manager.createIdentity(name: "Test", bio: "", links: [], loadStoredSecretKey: { nil }) XCTFail("Expected key derivation probe to stop creation") } catch { XCTAssertTrue(manager.didDeriveKeys) From 2365306ed48a366bbe555aa55e436143f7427f06 Mon Sep 17 00:00:00 2001 From: administrator Date: Tue, 8 Sep 2026 06:39:43 +0100 Subject: [PATCH 9/9] refactor: simplify pubky profile setup wiring --- Bitkit/AppScene.swift | 7 +- Bitkit/MainNavView.swift | 58 ++++++++----- Bitkit/ViewModels/AppViewModel.swift | 8 +- .../PendingProfileSetupResumeTests.swift | 87 +++++++++++++++++++ BitkitTests/ShopPaymentRequestTests.swift | 3 +- 5 files changed, 130 insertions(+), 33 deletions(-) create mode 100644 BitkitTests/PendingProfileSetupResumeTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index f0315347e..41a77410c 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -31,7 +31,7 @@ struct AppScene: View { @StateObject private var channelDetails = ChannelDetailsViewModel.shared @StateObject private var migrations = MigrationsService.shared @StateObject private var languageManager = LanguageManager.shared - @StateObject private var pubkyProfile: PubkyProfileManager + @StateObject private var pubkyProfile = PubkyProfileManager() @StateObject private var contactsManager = ContactsManager() @State private var keyboardManager = KeyboardManager() @State private var trezorManager: TrezorManager @@ -56,7 +56,6 @@ struct AppScene: View { init() { let sheetViewModel = SheetViewModel() let navigationViewModel = NavigationViewModel() - let pubkyProfile = PubkyProfileManager() let transferService = TransferService( lightningService: LightningService.shared, blocktankService: CoreService.shared.blocktank @@ -69,12 +68,10 @@ struct AppScene: View { _app = StateObject(wrappedValue: AppViewModel( sheetViewModel: sheetViewModel, - navigationViewModel: navigationViewModel, - pubkyProfile: pubkyProfile + navigationViewModel: navigationViewModel )) _sheets = StateObject(wrappedValue: sheetViewModel) _navigation = StateObject(wrappedValue: navigationViewModel) - _pubkyProfile = StateObject(wrappedValue: pubkyProfile) let feeEstimatesManager = FeeEstimatesManager() let walletVm = WalletViewModel( transferService: transferService, diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 91b9355cf..c66cf2fcf 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -1,12 +1,36 @@ import SwiftUI -struct MainNavView: View { - private enum PendingProfileSetupResumeState { - case inactive - case waiting - case ready +enum PendingProfileSetupResumeState { + case inactive + case waiting + case ready + + func shouldResume(didResume: inout Bool) -> Bool { + if self == .inactive { + didResume = false + } + guard self == .ready, !didResume else { return false } + didResume = true + return true } +} +func resolvePendingProfileSetupResumeState( + isProfileSetupPending: Bool, + isPaykitUIActive: Bool, + isAuthenticated: Bool, + hasActiveSheet: Bool, + isReplacingSheet: Bool, + currentRoute: Route? +) -> PendingProfileSetupResumeState { + guard isProfileSetupPending else { return .inactive } + guard isPaykitUIActive, isAuthenticated, !hasActiveSheet, !isReplacingSheet, currentRoute != .createProfile else { + return .waiting + } + return .ready +} + +struct MainNavView: View { @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @EnvironmentObject private var app: AppViewModel @@ -33,16 +57,14 @@ struct MainNavView: View { } private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { - guard pubkyProfile.isProfileSetupPending else { return .inactive } - guard isPaykitUIActive, - pubkyProfile.isAuthenticated, - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet, - navigation.currentRoute != .createProfile - else { - return .waiting - } - return .ready + resolvePendingProfileSetupResumeState( + isProfileSetupPending: pubkyProfile.isProfileSetupPending, + isPaykitUIActive: isPaykitUIActive, + isAuthenticated: pubkyProfile.isAuthenticated, + hasActiveSheet: sheets.activeSheetConfiguration != nil, + isReplacingSheet: sheets.isReplacingSheet, + currentRoute: navigation.currentRoute + ) } // Delay constants for clipboard processing @@ -60,11 +82,7 @@ struct MainNavView: View { } } .onChange(of: pendingProfileSetupResumeState, initial: true) { _, resumeState in - if resumeState == .inactive { - didResumePendingPubkyProfileSetup = false - } - guard resumeState == .ready, !didResumePendingPubkyProfileSetup else { return } - didResumePendingPubkyProfileSetup = true + guard resumeState.shouldResume(didResume: &didResumePendingPubkyProfileSetup) else { return } navigation.navigate(.createProfile) } .sheet( diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index fd2a86189..14764c2f3 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -119,7 +119,6 @@ class AppViewModel: ObservableObject { private let coreService: CoreService private let sheetViewModel: SheetViewModel private let navigationViewModel: NavigationViewModel - private let pubkyProfile: PubkyProfileManager private var scannedDataHandlingId: UUID? private var manualEntryValidationSequence: UInt64 = 0 @@ -131,14 +130,12 @@ class AppViewModel: ObservableObject { lightningService: LightningService = .shared, coreService: CoreService = .shared, sheetViewModel: SheetViewModel, - navigationViewModel: NavigationViewModel, - pubkyProfile: PubkyProfileManager + navigationViewModel: NavigationViewModel ) { self.lightningService = lightningService self.coreService = coreService self.sheetViewModel = sheetViewModel self.navigationViewModel = navigationViewModel - self.pubkyProfile = pubkyProfile setupManualEntryValidationDebounce() @@ -250,8 +247,7 @@ class AppViewModel: ObservableObject { convenience init() { self.init( sheetViewModel: SheetViewModel(), - navigationViewModel: NavigationViewModel(), - pubkyProfile: PubkyProfileManager() + navigationViewModel: NavigationViewModel() ) } diff --git a/BitkitTests/PendingProfileSetupResumeTests.swift b/BitkitTests/PendingProfileSetupResumeTests.swift new file mode 100644 index 000000000..b5f77e2e2 --- /dev/null +++ b/BitkitTests/PendingProfileSetupResumeTests.swift @@ -0,0 +1,87 @@ +@testable import Bitkit +import XCTest + +final class PendingProfileSetupResumeTests: XCTestCase { + func testResumeReadiness() { + let cases: [(pending: Bool, paykitActive: Bool, authenticated: Bool, sheet: Bool, replacing: Bool, route: Route?, + expected: PendingProfileSetupResumeState)] = [ + (false, true, true, false, false, nil, .inactive), + (false, false, false, true, true, .createProfile, .inactive), + (true, false, true, false, false, nil, .waiting), + (true, true, false, false, false, nil, .waiting), + (true, true, true, true, false, nil, .waiting), + (true, true, true, false, true, nil, .waiting), + (true, true, true, false, false, .createProfile, .waiting), + (true, true, true, false, false, nil, .ready), + (true, true, true, false, false, .settings, .ready), + ] + + for (index, testCase) in cases.enumerated() { + XCTAssertEqual( + resolvePendingProfileSetupResumeState( + isProfileSetupPending: testCase.pending, + isPaykitUIActive: testCase.paykitActive, + isAuthenticated: testCase.authenticated, + hasActiveSheet: testCase.sheet, + isReplacingSheet: testCase.replacing, + currentRoute: testCase.route + ), + testCase.expected, + "Case \(index)" + ) + } + } + + func testWaitingPreservesResumeLatchUntilPendingSetupClears() { + for alreadyResumed in [false, true] { + var didResume = alreadyResumed + let waiting = resolvePendingProfileSetupResumeState( + isProfileSetupPending: true, + isPaykitUIActive: true, + isAuthenticated: true, + hasActiveSheet: true, + isReplacingSheet: false, + currentRoute: nil + ) + + XCTAssertFalse(waiting.shouldResume(didResume: &didResume)) + XCTAssertEqual(didResume, alreadyResumed) + XCTAssertEqual(PendingProfileSetupResumeState.ready.shouldResume(didResume: &didResume), !alreadyResumed) + XCTAssertTrue(didResume) + XCTAssertFalse(PendingProfileSetupResumeState.ready.shouldResume(didResume: &didResume)) + + let inactive = resolvePendingProfileSetupResumeState( + isProfileSetupPending: false, + isPaykitUIActive: true, + isAuthenticated: false, + hasActiveSheet: false, + isReplacingSheet: false, + currentRoute: nil + ) + XCTAssertFalse(inactive.shouldResume(didResume: &didResume)) + XCTAssertFalse(didResume) + XCTAssertTrue(PendingProfileSetupResumeState.ready.shouldResume(didResume: &didResume)) + } + } + + func testLeavingCreateProfileDoesNotResumeAgain() { + var didResume = false + var resumedRoutes: [Route] = [] + + for route: Route? in [nil, .createProfile, nil, .settings] { + let state = resolvePendingProfileSetupResumeState( + isProfileSetupPending: true, + isPaykitUIActive: true, + isAuthenticated: true, + hasActiveSheet: false, + isReplacingSheet: false, + currentRoute: route + ) + if state.shouldResume(didResume: &didResume) { + resumedRoutes.append(.createProfile) + } + } + + XCTAssertEqual(resumedRoutes, [.createProfile]) + } +} diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index f1f705ec2..ec457ace8 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -66,8 +66,7 @@ final class ShopPaymentRequestTests: XCTestCase { let sheets = SheetViewModel() let app = AppViewModel( sheetViewModel: sheets, - navigationViewModel: NavigationViewModel(), - pubkyProfile: PubkyProfileManager() + navigationViewModel: NavigationViewModel() ) try await app.handleScannedData(url)