diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index ab8f05a7f..790311d36 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -146,6 +146,7 @@ struct AppScene: View {
.onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) }
.onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) }
.onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) }
+ .onOpenURL { url in app.retainDeepLink(url) }
// Bridge Trezor device state into the watch-only manager without coupling the two:
// TrezorManager bumps devicesRevision on any device/connection change.
.onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() }
@@ -283,6 +284,10 @@ struct AppScene: View {
isPinVerified = true
}
+ if let url = DeepLinkRouter.shared.consume() {
+ app.retainDeepLink(url)
+ }
+
// Listen for quick action notifications
NotificationCenter.default.addObserver(
forName: .quickActionSelected,
@@ -291,6 +296,13 @@ struct AppScene: View {
) { notification in
handleQuickAction(notification)
}
+ NotificationCenter.default.addObserver(
+ forName: .deepLinkReceived,
+ object: nil,
+ queue: .main
+ ) { notification in
+ handleDeepLinkNotification(notification)
+ }
}
.onReceive(BackupService.shared.backupFailurePublisher) { intervalMinutes in
handleBackupFailure(intervalMinutes: intervalMinutes)
@@ -301,6 +313,16 @@ struct AppScene: View {
}
}
+ private func handleDeepLinkNotification(_ notification: Notification) {
+ if let retainedURL = DeepLinkRouter.shared.consume() {
+ app.retainDeepLink(retainedURL)
+ return
+ }
+ if let receivedURL = notification.object as? URL {
+ app.retainDeepLink(receivedURL)
+ }
+ }
+
private var mainContent: some View {
ZStack {
if Env.isTrezorEmulatorTesting {
diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index be9e09e1a..268d511bd 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -5,6 +5,7 @@ import SwiftUI
/// Communication bridge between delegates and SwiftUI views
extension Notification.Name {
static let quickActionSelected = Notification.Name("quickActionSelected")
+ static let deepLinkReceived = Notification.Name("deepLinkReceived")
}
class AppDelegate: NSObject, UIApplicationDelegate {
@@ -39,6 +40,15 @@ class AppDelegate: NSObject, UIApplicationDelegate {
return config
}
+ func application(
+ _ application: UIApplication,
+ open url: URL,
+ options: [UIApplication.OpenURLOptionsKey: Any] = [:]
+ ) -> Bool {
+ DeepLinkRouter.shared.forward(url)
+ return true
+ }
+
// MARK: - App Termination
func applicationWillTerminate(_ application: UIApplication) {
diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist
index 020553009..5e1652411 100644
--- a/Bitkit/Info.plist
+++ b/Bitkit/Info.plist
@@ -37,7 +37,7 @@
$(TREZOR_ELECTRUM_URL)
LSApplicationQueriesSchemes
- pubkyauth
+ pubkyring
NSAppTransportSecurity
diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift
index a1b201357..da674bfa3 100644
--- a/Bitkit/MainNavView.swift
+++ b/Bitkit/MainNavView.swift
@@ -1,6 +1,8 @@
import SwiftUI
struct MainNavView: View {
+ private let canHandleDeepLinks: Bool
+
@AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false
@EnvironmentObject private var app: AppViewModel
@@ -21,6 +23,10 @@ struct MainNavView: View {
@State private var showClipboardAlert = false
@State private var clipboardUri: String?
+ init(canHandleDeepLinks: Bool = true) {
+ self.canHandleDeepLinks = canHandleDeepLinks
+ }
+
private var isPaykitUIActive: Bool {
PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled
}
@@ -317,69 +323,13 @@ struct MainNavView: View {
notificationManager.unregister()
}
}
- .onOpenURL { url in
- Task {
- Logger.info("Received deeplink: \(sanitizedDeeplinkDescription(url))")
-
- // Web URLs from widgets (e.g. news article tap) bypass payment handling
- if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
- await UIApplication.shared.open(url)
- return
- }
-
- if let callback = PubkyRingAuthCallback.parse(url: url) {
- guard isPaykitUIActive else {
- app.toast(
- type: .error,
- title: t("profile__auth_error_title"),
- description: t("other__qr_error_text")
- )
- return
- }
-
- let handlingResult = await pubkyProfile.handleAuthCallback(callback)
-
- switch handlingResult {
- case let .trustedError(message):
- app.toast(
- type: .error,
- title: t("profile__auth_error_title"),
- description: message ?? t("other__qr_error_text")
- )
- case .untrustedError:
- app.toast(
- type: .error,
- title: t("profile__auth_error_title")
- )
- case .handled, .ignored:
- break
- }
-
- return
- }
-
- do {
- try await app.handleScannedData(
- url.absoluteString,
- alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
- )
- if shouldOpenPaymentSheet(for: url.absoluteString) {
- PaymentNavigationHelper.openPaymentSheet(
- app: app,
- currency: currency,
- settings: settings,
- sheetViewModel: sheets
- )
- }
- } catch {
- Logger.error(error, context: "Failed to handle deeplink")
- app.toast(
- type: .error,
- title: t("other__qr_error_header"),
- description: t("other__qr_error_text")
- )
- }
- }
+ .task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running]) {
+ guard canHandleDeepLinks else { return }
+ await handlePendingDeepLink()
+ }
+ .onChange(of: app.pendingDeepLinkURL) { _, url in
+ guard canHandleDeepLinks, url != nil else { return }
+ Task { await handlePendingDeepLink() }
}
.alert(
t("other__clipboard_redirect_title"),
@@ -698,6 +648,78 @@ struct MainNavView: View {
!SamRockSetupRequest.isProtocolURL(uri) && !PubkyAuthRequest.isProtocolURL(uri)
}
+ private func handlePendingDeepLink() async {
+ await app.routePendingDeepLinkIfReady(
+ canHandleDeepLinks,
+ nodeIsRunning: wallet.nodeLifecycleState == .running
+ ) { url in
+ await handleDeepLink(url)
+ }
+ }
+
+ private func handleDeepLink(_ url: URL) async {
+ Logger.info("Received deeplink: \(sanitizedDeeplinkDescription(url))")
+
+ // Web URLs from widgets (e.g. news article tap) bypass payment handling
+ if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
+ await UIApplication.shared.open(url)
+ return
+ }
+
+ if let callback = PubkyRingAuthCallback.parse(url: url) {
+ guard isPaykitUIActive else {
+ app.toast(
+ type: .error,
+ title: t("profile__auth_error_title"),
+ description: t("other__qr_error_text")
+ )
+ return
+ }
+
+ let handlingResult = await pubkyProfile.handleAuthCallback(callback)
+
+ switch handlingResult {
+ case let .trustedError(message):
+ app.toast(
+ type: .error,
+ title: t("profile__auth_error_title"),
+ description: message ?? t("other__qr_error_text")
+ )
+ case .untrustedError:
+ app.toast(
+ type: .error,
+ title: t("profile__auth_error_title")
+ )
+ case .handled, .ignored:
+ break
+ }
+
+ return
+ }
+
+ do {
+ try await app.handleScannedData(
+ url.absoluteString,
+ alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats
+ )
+ if shouldOpenPaymentSheet(for: url.absoluteString) {
+ PaymentNavigationHelper.openPaymentSheet(
+ app: app,
+ currency: currency,
+ settings: settings,
+ sheetViewModel: sheets
+ )
+ }
+ } catch {
+ Logger.error(error, context: "Failed to handle deeplink")
+ app.toast(
+ type: .error,
+ title: t("other__qr_error_header"),
+ description: t("other__qr_error_text")
+ )
+ }
+ }
+
private func sanitizedDeeplinkDescription(_ url: URL) -> String {
if let description = SamRockSetupRequest.sanitizedDescription(url.absoluteString) {
return description
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
index b3da17c3d..05efad323 100644
--- a/Bitkit/Managers/PubkyProfileManager.swift
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -95,6 +95,17 @@ enum PubkyRingAuthURLBuilder {
return components.url?.absoluteString
}
+ static func ringHandoffURL(from authUrl: String) -> URL? {
+ guard var components = URLComponents(string: authUrl), components.scheme?.lowercased() == "pubkyauth" else {
+ return nil
+ }
+
+ components.scheme = "pubkyring"
+ components.host = "signin"
+ components.path = ""
+ return components.url
+ }
+
private static func callbackUrl(_ baseUrl: String, nonce: UUID?) -> String {
guard let nonce else {
return baseUrl
@@ -389,7 +400,7 @@ class PubkyProfileManager: ObservableObject {
}
static func isRingAvailable() -> Bool {
- guard let url = URL(string: "pubkyauth://check") else {
+ guard let url = URL(string: "pubkyring://check") else {
return false
}
@@ -477,7 +488,7 @@ class PubkyProfileManager: ObservableObject {
let callbackAuthUrl = PubkyRingAuthURLBuilder.addingCallbacks(to: authUrl, nonce: attemptID) ?? authUrl
- guard let url = URL(string: callbackAuthUrl) else {
+ guard let url = PubkyRingAuthURLBuilder.ringHandoffURL(from: callbackAuthUrl) else {
await cancelPendingAuthSetup()
activeAuthAttemptID = nil
restoreAuthStateAfterAuthFlow()
diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift
index 8b06f15c1..a63d00bf3 100644
--- a/Bitkit/Models/PubkyAuthRequest.swift
+++ b/Bitkit/Models/PubkyAuthRequest.swift
@@ -26,6 +26,8 @@ enum PubkyAuthRequestError: Error, Equatable {
case invalidUrl
case missingBitkitClaim
case duplicateBitkitClaim
+ case duplicateRelay
+ case duplicateSecret
case unsupportedBitkitClaim(String)
case invalidBitkitClaimCapabilities
}
@@ -51,6 +53,9 @@ struct PubkyAuthPermission {
// MARK: - PubkyAuth Request (parsed from pubkyauth:// URL)
struct PubkyAuthRequest {
+ private static let bitkitSetupHost = "pubky-auth"
+ private static let bitkitSetupPath = "/setup"
+
let rawUrl: String
let kind: Paykit.PubkyAuthRequestKind
let relay: String
@@ -59,21 +64,56 @@ struct PubkyAuthRequest {
let serviceNames: [String]
let bitkitClaim: PubkyAuthClaim?
+ /// The network origin that receives the authorization. This is a delivery destination, not a service identity.
+ var relayOrigin: String? {
+ guard let components = URLComponents(string: relay),
+ let scheme = components.scheme?.lowercased(),
+ ["http", "https"].contains(scheme),
+ let host = components.host?.lowercased(),
+ !host.isEmpty
+ else {
+ return nil
+ }
+
+ let port = components.port.map { ":\($0)" } ?? ""
+ return "\(scheme)://\(host)\(port)"
+ }
+
static func isProtocolURL(_ value: String) -> Bool {
- URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth"
+ URLComponents(string: normalizedProtocolURL(value).trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth"
+ }
+
+ /// Normalizes Bitkit's unique iOS handoff because the OS cannot deterministically route a custom scheme shared with Pubky Ring.
+ static func normalizedProtocolURL(_ value: String) -> String {
+ let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard isBitkitSetupHandoff(trimmedValue),
+ let queryDelimiter = trimmedValue.firstIndex(of: "?")
+ else {
+ return value
+ }
+
+ let queryStart = trimmedValue.index(after: queryDelimiter)
+ return "pubkyauth://signin?\(trimmedValue[queryStart...])"
}
static func parse(url: String) throws -> PubkyAuthRequest {
- let details = try Paykit.parsePubkyAuthUrl(authUrl: url)
+ let requiresBitkitClaim = isBitkitSetupHandoff(url.trimmingCharacters(in: .whitespacesAndNewlines))
+ let normalizedURL = normalizedProtocolURL(url)
+ try rejectDuplicateRelayAndSecret(in: normalizedURL)
+ let details = try Paykit.parsePubkyAuthUrl(authUrl: normalizedURL)
let capabilities = details.capabilities ?? ""
let permissions = parseCapabilities(capabilities)
var seenServiceNames = Set()
let serviceNames = permissions
.compactMap { extractServiceName($0.path) }
.filter { seenServiceNames.insert($0).inserted }
- let bitkitClaim = try parseBitkitClaim(url: url, capabilities: capabilities)
+ let bitkitClaim = try parseBitkitClaim(
+ url: normalizedURL,
+ capabilities: capabilities,
+ requiresBitkitClaim: requiresBitkitClaim
+ )
return PubkyAuthRequest(
- rawUrl: url,
+ rawUrl: normalizedURL,
kind: details.kind,
relay: details.relayUrl ?? "",
capabilities: capabilities,
@@ -83,7 +123,17 @@ struct PubkyAuthRequest {
)
}
- static func parseBitkitClaim(url: String, capabilities: String) throws -> PubkyAuthClaim? {
+ private static func rejectDuplicateRelayAndSecret(in url: String) throws {
+ guard let items = URLComponents(string: url)?.queryItems else { return }
+ if items.filter({ $0.name == "relay" }).count > 1 {
+ throw PubkyAuthRequestError.duplicateRelay
+ }
+ if items.filter({ $0.name == "secret" }).count > 1 {
+ throw PubkyAuthRequestError.duplicateSecret
+ }
+ }
+
+ static func parseBitkitClaim(url: String, capabilities: String, requiresBitkitClaim: Bool = false) throws -> PubkyAuthClaim? {
guard let components = URLComponents(string: url) else {
throw PubkyAuthRequestError.invalidUrl
}
@@ -96,7 +146,7 @@ struct PubkyAuthRequest {
throw PubkyAuthRequestError.duplicateBitkitClaim
}
guard let claimValue = claimValues.first else {
- if PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) {
+ if requiresBitkitClaim || PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) {
throw PubkyAuthRequestError.missingBitkitClaim
}
return nil
@@ -111,6 +161,25 @@ struct PubkyAuthRequest {
return claim
}
+ private static func isBitkitSetupHandoff(_ value: String) -> Bool {
+ guard let components = URLComponents(string: value),
+ components.scheme?.lowercased() == "bitkit",
+ components.host?.lowercased() == bitkitSetupHost,
+ components.path == bitkitSetupPath,
+ components.user == nil,
+ components.password == nil,
+ components.port == nil,
+ components.fragment == nil,
+ let query = components.percentEncodedQuery,
+ !query.isEmpty,
+ !query.hasPrefix("?")
+ else {
+ return false
+ }
+
+ return true
+ }
+
static func parseCapabilities(_ caps: String) -> [PubkyAuthPermission] {
caps
.split(separator: ",")
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 634feaa4b..c5821ce38 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -699,10 +699,12 @@
"pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters.";
"pubky_auth__watch_only_intro_approve" = "Approve";
"pubky_auth__watch_only_intro_description" = "To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds.";
+"pubky_auth__watch_only_intro_relay" = "Your authorization will be delivered to {relay}.";
"pubky_auth__watch_only_intro_nav_title" = "Earn";
"pubky_auth__watch_only_intro_title" = "EARN BITCOIN\nFROM YOUR\nCONTENT";
"pubky_auth__watch_only_account_xpub_error" = "Bitkit could not create a valid account xpub.";
"pubky_auth__trust_warning" = "Make sure you trust the service, browser, or device before authorizing with your pubky.";
+"pubky_auth__authorization_relay" = "AUTHORIZATION RELAY";
"pubky_auth__authorizing" = "Authorizing...";
"pubky_auth__success_title" = "Authorization Successful";
"pubky_auth__success_prefix" = "You authorized with pubky ";
diff --git a/Bitkit/SceneDelegate.swift b/Bitkit/SceneDelegate.swift
index 51e36c932..570d4e637 100644
--- a/Bitkit/SceneDelegate.swift
+++ b/Bitkit/SceneDelegate.swift
@@ -1,6 +1,26 @@
import SwiftUI
import UIKit
+final class DeepLinkRouter {
+ static let shared = DeepLinkRouter()
+
+ private var pendingURL: URL?
+
+ func retain(_ url: URL) {
+ pendingURL = url
+ }
+
+ func forward(_ url: URL) {
+ retain(url)
+ NotificationCenter.default.post(name: .deepLinkReceived, object: url)
+ }
+
+ func consume() -> URL? {
+ defer { pendingURL = nil }
+ return pendingURL
+ }
+}
+
// MARK: - Scene Delegate for Quick Actions
/// Handles scene lifecycle and quick actions for SwiftUI apps
@@ -8,6 +28,7 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate {
// MARK: - Quick Action State
var savedShortCutItem: UIApplicationShortcutItem?
+ var savedDeepLinkURL: URL?
// MARK: - Scene Connection
@@ -16,6 +37,7 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate {
if let shortcutItem = connectionOptions.shortcutItem {
savedShortCutItem = shortcutItem
}
+ savedDeepLinkURL = connectionOptions.urlContexts.first?.url
}
// MARK: - Scene Activation
@@ -26,6 +48,10 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate {
handleQuickAction(shortcutItem)
savedShortCutItem = nil
}
+ if let url = savedDeepLinkURL {
+ forwardDeepLink(url)
+ savedDeepLinkURL = nil
+ }
}
// MARK: - Quick Action Handling (App Running)
@@ -40,6 +66,12 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate {
completionHandler(true)
}
+ func scene(_ scene: UIScene, openURLContexts URLContexts: Set) {
+ for context in URLContexts {
+ forwardDeepLink(context.url)
+ }
+ }
+
// MARK: - Quick Action Processing
/// Process quick action and notify SwiftUI views
@@ -47,4 +79,8 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate {
let userInfo = ["shortcutType": shortcutItem.type]
NotificationCenter.default.post(name: .quickActionSelected, object: nil, userInfo: userInfo)
}
+
+ func forwardDeepLink(_ url: URL) {
+ DeepLinkRouter.shared.forward(url)
+ }
}
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 4fb599759..f122d5a3d 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -67,6 +67,8 @@ class AppViewModel: ObservableObject {
@Published var lnurlPayData: LnurlPayData?
@Published var lnurlWithdrawData: LnurlWithdrawData?
+ @Published private(set) var pendingDeepLinkURL: URL?
+
// Onboarding
@AppStorage("hasDismissedWidgetsOnboardingHint") var hasDismissedWidgetsOnboardingHint: Bool = false
@AppStorage("hasSeenContactsIntro") var hasSeenContactsIntro: Bool = false
@@ -115,6 +117,54 @@ class AppViewModel: ObservableObject {
appStatusInit = true
}
+ func retainDeepLink(_ url: URL) {
+ pendingDeepLinkURL = url
+ }
+
+ func routePendingDeepLinkIfReady(_ isReady: Bool, nodeIsRunning: Bool = false, handler: (URL) async -> Void) async {
+ guard isReady, let url = pendingDeepLinkURL else { return }
+ if Self.requiresLightningNode(url), !nodeIsRunning {
+ return
+ }
+ pendingDeepLinkURL = nil
+ await handler(url)
+ }
+
+ private static func requiresLightningNode(_ url: URL) -> Bool {
+ if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
+ return false
+ }
+ if PubkyRingAuthCallback.parse(url: url) != nil {
+ return false
+ }
+ if url.scheme?.lowercased() == "bitkit",
+ url.host?.lowercased() == "pubky-auth",
+ url.path == "/setup"
+ {
+ return false
+ }
+ if SamRockSetupRequest.isProtocolURL(url.absoluteString) {
+ return false
+ }
+ if url.scheme?.lowercased() == "bitcoin" {
+ return false
+ }
+ if isBolt11Invoice(url) {
+ return false
+ }
+ if url.scheme?.lowercased() == "bitkit",
+ url.host?.lowercased().hasPrefix("gift-") == true
+ {
+ return false
+ }
+ return !PubkyAuthRequest.isProtocolURL(url.absoluteString)
+ }
+
+ private static func isBolt11Invoice(_ url: URL) -> Bool {
+ let invoice = url.absoluteString.removingLightningSchemes().trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return invoice.hasPrefix("lnbc") || invoice.hasPrefix("lntb")
+ }
+
private let lightningService: LightningService
private let coreService: CoreService
private let sheetViewModel: SheetViewModel
@@ -436,7 +486,8 @@ extension AppViewModel {
}
}
- let uri = uri.removingLightningSchemes()
+ let sourceURI = uri.removingLightningSchemes()
+ let uri = PubkyAuthRequest.normalizedProtocolURL(sourceURI)
let prevalidatedPaymentRequest: BitkitCore.Scanner?
if scope == .paymentRequests {
guard SamRockSetupRequest.parse(uri) == nil,
@@ -649,7 +700,7 @@ extension AppViewModel {
}
handleNodeUri(url)
- case let .pubkyAuth(data: authUrl):
+ case .pubkyAuth:
guard PaykitFeatureFlags.isUIEnabled else {
toast(
type: .error,
@@ -659,7 +710,7 @@ extension AppViewModel {
)
return
}
- handlePubkyAuthApproval(authUrl)
+ handlePubkyAuthApproval(sourceURI)
case let .gift(code, amount):
sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount)))
default:
@@ -786,7 +837,7 @@ extension AppViewModel {
sheetViewModel.showSheet(.lnurlAuth, data: LnurlAuthConfig(lnurl: lnurl, authData: data))
}
- private func handlePubkyAuthApproval(_ authUrl: String) {
+ private func handlePubkyAuthApproval(_ sourceURL: String) {
// State 1: No Pubky identity at all
guard (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false else {
toast(type: .warning, title: t("pubky_auth__no_identity"), description: t("pubky_auth__no_identity_desc"))
@@ -803,11 +854,15 @@ extension AppViewModel {
// State 3: Bitkit-generated identity — can approve
do {
- let request = try PubkyAuthRequest.parse(url: authUrl)
- sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: authUrl, request: request))
+ let request = try PubkyAuthRequest.parse(url: sourceURL)
+ sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: request.rawUrl, request: request))
} catch {
Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel")
- toast(type: .error, title: t("pubky_auth__invalid_request"))
+ toast(
+ type: .error,
+ title: t("pubky_auth__invalid_request"),
+ accessibilityIdentifier: "PubkyAuthInvalidRequestToast"
+ )
}
}
diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift
index 2d79b2076..dae67f596 100644
--- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift
+++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift
@@ -131,7 +131,7 @@ struct PubkyAuthApprovalSheet: View {
SheetIntro(
navTitle: t("pubky_auth__watch_only_intro_nav_title"),
title: t("pubky_auth__watch_only_intro_title"),
- description: t("pubky_auth__watch_only_intro_description"),
+ description: watchOnlyConsentDescription,
image: "coin-stack",
continueText: t("pubky_auth__watch_only_intro_approve"),
cancelText: t("common__cancel"),
@@ -231,6 +231,11 @@ struct PubkyAuthApprovalSheet: View {
descriptionText
.padding(.bottom, 32)
+ if let relayOrigin = config.request.relayOrigin {
+ relayOriginSection(relayOrigin)
+ .padding(.bottom, 24)
+ }
+
permissionsSection
Spacer(minLength: 32)
@@ -260,6 +265,25 @@ struct PubkyAuthApprovalSheet: View {
.lineSpacing(4)
}
+ private var watchOnlyConsentDescription: String {
+ let description = t("pubky_auth__watch_only_intro_description")
+ guard let relayOrigin = config.request.relayOrigin else { return description }
+
+ return description + "\n\n" + t(
+ "pubky_auth__watch_only_intro_relay",
+ variables: ["relay": relayOrigin]
+ )
+ }
+
+ private func relayOriginSection(_ relayOrigin: String) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("pubky_auth__authorization_relay"), textColor: .white64)
+ BodySSBText(relayOrigin)
+ .accessibilityIdentifier("PubkyAuthRelayOrigin")
+ CustomDivider(color: .white10)
+ }
+ }
+
private var successDescriptionText: some View {
BodyMText(
t("pubky_auth__success_prefix") + "" + truncatedPublicKey + ""
diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift
index b1b4af240..583172db0 100644
--- a/BitkitTests/PubkyAuthRequestTests.swift
+++ b/BitkitTests/PubkyAuthRequestTests.swift
@@ -13,6 +13,112 @@ final class PubkyAuthRequestTests: XCTestCase {
XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example"))
}
+ func testProtocolUrlNormalizesBitkitSpecificSetupHandoff() throws {
+ let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" +
+ "&relay=\(relay)&secret=\(secret)&x-bitkit-claim=watch-only-account-v1"
+
+ XCTAssertTrue(PubkyAuthRequest.isProtocolURL(url))
+
+ let request = try PubkyAuthRequest.parse(url: url)
+
+ XCTAssertTrue(request.rawUrl.hasPrefix("pubkyauth://signin?"))
+ XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1)
+ XCTAssertEqual(request.capabilities, PubkyAuthClaim.watchOnlyAccountCapabilities)
+ }
+
+ func testRelayOriginShowsOnlyTheAuthorizationDestination() throws {
+ let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" +
+ "&relay=https%3A%2F%2FRelay.Example%3A8443%2Finbox%2F&secret=\(secret)&x-bitkit-claim=watch-only-account-v1"
+
+ let request = try PubkyAuthRequest.parse(url: url)
+
+ XCTAssertEqual(request.relayOrigin, "https://relay.example:8443")
+ }
+
+ func testProtocolUrlRejectsBitkitSpecificSetupHandoffWithoutClaimMarker() {
+ let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" +
+ "&relay=\(relay)&secret=\(secret)"
+
+ XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) {
+ XCTAssertEqual($0 as? PubkyAuthRequestError, .missingBitkitClaim)
+ }
+ }
+
+ func testProtocolUrlRejectsGenericBitkitSetupHandoffWithoutClaimMarker() {
+ let url = "bitkit://pubky-auth/setup?caps=/pub/locks.app/:rw&relay=\(relay)&secret=\(secret)"
+
+ XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) {
+ XCTAssertEqual($0 as? PubkyAuthRequestError, .missingBitkitClaim)
+ }
+ }
+
+ func testProtocolUrlDoesNotTreatPubkyRingCallbackAsSetupHandoff() {
+ let url = "bitkit://pubky-auth/success?nonce=123"
+
+ XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url))
+ XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url)
+ }
+
+ func testProtocolUrlRejectsSetupHandoffWithUserInfoOrPort() {
+ let query = "caps=&relay=https%3A%2F%2Fx&secret=first"
+ let urls = [
+ "bitkit://user@pubky-auth/setup?\(query)",
+ "bitkit://pubky-auth:123/setup?\(query)",
+ ]
+
+ for url in urls {
+ XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url))
+ XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url)
+ }
+ }
+
+ func testProtocolUrlRejectsFragment() {
+ let query = "caps=a%2Fb&relay=https%3A%2F%2Fx&secret=first&secret=second"
+ let url = "bitkit://pubky-auth/setup?\(query)#ignored"
+
+ XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url))
+ XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url)
+ XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url))
+ }
+
+ func testProtocolUrlDoesNotReserializeRawOrEncodedQueryBytes() {
+ let query = "caps=%23encoded&relay=https%3A%2F%2Fx&secret=first&secret=second"
+
+ XCTAssertEqual(
+ PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)"),
+ "pubkyauth://signin?\(query)"
+ )
+ }
+
+ func testProtocolUrlRejectsBitkitSetupHandoffWithoutQuery() {
+ let url = "bitkit://pubky-auth/setup"
+
+ XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url))
+ XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url)
+ XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url))
+ }
+
+ func testProtocolUrlRejectsEmptyOrDuplicateQueryDelimiter() {
+ let urls = [
+ "bitkit://pubky-auth/setup?",
+ "bitkit://pubky-auth/setup??secret=first",
+ ]
+
+ for url in urls {
+ XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url))
+ XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url)
+ XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url))
+ }
+ }
+
+ func testProtocolUrlDoesNotTreatFragmentQuestionMarkAsQuery() {
+ let url = "bitkit://pubky-auth/setup#ignored?caps="
+
+ XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url))
+ XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url)
+ XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url))
+ }
+
func testParseUrlPreservesRequestedCapabilities() throws {
let capabilities = "/pub/bitkit.to/:rw"
let url = "pubkyauth://signin?caps=\(capabilities)&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s"
@@ -97,6 +203,22 @@ final class PubkyAuthRequestTests: XCTestCase {
}
}
+ func testParseUrlRejectsDuplicateRelay() {
+ let url = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://a&relay=https://b&secret=\(secret)"
+
+ XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) {
+ XCTAssertEqual($0 as? PubkyAuthRequestError, .duplicateRelay)
+ }
+ }
+
+ func testParseUrlRejectsDuplicateSecret() {
+ let url = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://a&secret=first&secret=second"
+
+ XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) {
+ XCTAssertEqual($0 as? PubkyAuthRequestError, .duplicateSecret)
+ }
+ }
+
func testParseUrlRejectsUnknownBitkitClaim() {
let url = authUrl(capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, claimValues: ["unknown-v1"])
diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift
new file mode 100644
index 000000000..49b97f8f7
--- /dev/null
+++ b/BitkitTests/PubkyAuthURLSchemeTests.swift
@@ -0,0 +1,186 @@
+@testable import Bitkit
+import XCTest
+
+final class PubkyAuthURLSchemeTests: XCTestCase {
+ func testAppUsesUniqueBitkitSchemeInsteadOfSharedPubkyAuthScheme() throws {
+ let urlTypes = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]])
+ let schemes = urlTypes.flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] }
+
+ XCTAssertTrue(schemes.contains("bitkit"))
+ XCTAssertFalse(schemes.contains("pubkyauth"))
+ }
+
+ func testAppQueriesPubkyRingSpecificOutboundURLScheme() throws {
+ let schemes = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "LSApplicationQueriesSchemes") as? [String])
+
+ XCTAssertTrue(schemes.contains("pubkyring"))
+ }
+
+ @MainActor
+ func testAppDefersGatedPubkyAuthURLAndRoutesWatchOnlyConsentExactlyOnce() async throws {
+ let hadPreviousPaykitUIValue = UserDefaults.standard.object(forKey: PaykitFeatureFlags.uiEnabledKey) != nil
+ let previousPaykitUIValue = UserDefaults.standard.bool(forKey: PaykitFeatureFlags.uiEnabledKey)
+ let previousSession = try? Keychain.loadString(key: .paykitSession)
+ let previousSecretKey = try? Keychain.loadString(key: .pubkySecretKey)
+ try Keychain.delete(key: .paykitSession)
+ try Keychain.delete(key: .pubkySecretKey)
+ try Keychain.saveString(key: .paykitSession, str: "test-session")
+ try Keychain.saveString(key: .pubkySecretKey, str: "test-secret-key")
+ UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey)
+ addTeardownBlock {
+ try? Keychain.delete(key: .paykitSession)
+ try? Keychain.delete(key: .pubkySecretKey)
+ if let previousSession {
+ try? Keychain.saveString(key: .paykitSession, str: previousSession)
+ }
+ if let previousSecretKey {
+ try? Keychain.saveString(key: .pubkySecretKey, str: previousSecretKey)
+ }
+ if hadPreviousPaykitUIValue {
+ UserDefaults.standard.set(previousPaykitUIValue, forKey: PaykitFeatureFlags.uiEnabledKey)
+ } else {
+ UserDefaults.standard.removeObject(forKey: PaykitFeatureFlags.uiEnabledKey)
+ }
+ }
+
+ let sheets = SheetViewModel()
+ let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel())
+ let url = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" +
+ "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" +
+ "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1"))
+ var routeCount = 0
+
+ app.retainDeepLink(url)
+ for gate in ["startup", "restoration", "PIN"] {
+ await app.routePendingDeepLinkIfReady(false) { _ in
+ XCTFail("The \(gate) gate must retain the URL while main navigation is hidden")
+ }
+ XCTAssertEqual(app.pendingDeepLinkURL, url)
+ }
+
+ await app.routePendingDeepLinkIfReady(true) { routedURL in
+ routeCount += 1
+ do {
+ try await app.handleScannedData(routedURL.absoluteString)
+ } catch {
+ XCTFail("The retained URL must route through the production scanner: \(error)")
+ }
+ }
+ await app.routePendingDeepLinkIfReady(true) { _ in
+ routeCount += 1
+ }
+
+ XCTAssertEqual(routeCount, 1)
+ XCTAssertNil(app.pendingDeepLinkURL)
+ XCTAssertEqual(sheets.activeSheetConfiguration?.id, .pubkyAuthApproval)
+ let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? PubkyAuthApprovalConfig)
+ XCTAssertEqual(config.request.bitkitClaim, .watchOnlyAccountV1)
+ XCTAssertTrue(config.authUrl.hasPrefix("pubkyauth://signin?"))
+
+ sheets.hideSheet()
+ let markerlessURL = "bitkit://pubky-auth/setup?caps=/pub/locks.app/:rw" +
+ "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" +
+ "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s"
+ try await app.handleScannedData(markerlessURL)
+
+ XCTAssertNil(sheets.activeSheetConfiguration)
+
+ let duplicateRelayURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" +
+ "&relay=https%3A%2F%2Fa&relay=https%3A%2F%2Fb" +
+ "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1"
+ try await app.handleScannedData(duplicateRelayURL)
+ XCTAssertNil(sheets.activeSheetConfiguration)
+
+ let duplicateSecretURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" +
+ "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" +
+ "&secret=first&secret=second&x-bitkit-claim=watch-only-account-v1"
+ try await app.handleScannedData(duplicateSecretURL)
+ XCTAssertNil(sheets.activeSheetConfiguration)
+ }
+
+ @MainActor
+ func testNonNodeDeepLinksReleaseAfterStartupGatesWithoutWaitingForLDK() async throws {
+ let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel())
+ let pubkyURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" +
+ "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" +
+ "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1"))
+ let httpURL = try XCTUnwrap(URL(string: "https://example.com/article"))
+ let ringURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/success"))
+ let malformedPubkyURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup"))
+ let lightningSamRockURL = try XCTUnwrap(
+ URL(string: "lightning:https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123")
+ )
+ let lnurlSamRockURL = try XCTUnwrap(
+ URL(string: "lnurl:https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123")
+ )
+ let bitcoinURL = try XCTUnwrap(URL(string: "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001"))
+ let bolt11URL = try XCTUnwrap(URL(string: "lightning:lnbc1example"))
+ let giftURL = try XCTUnwrap(URL(string: "bitkit://gift-code-1000"))
+ let lnurlURL = try XCTUnwrap(URL(string: "lnurl:lnurl1example"))
+
+ app.retainDeepLink(pubkyURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, pubkyURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
+ app.retainDeepLink(httpURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, httpURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
+ app.retainDeepLink(ringURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, ringURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
+ app.retainDeepLink(malformedPubkyURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, malformedPubkyURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
+ app.retainDeepLink(lightningSamRockURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, lightningSamRockURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
+ app.retainDeepLink(lnurlSamRockURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, lnurlSamRockURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
+ app.retainDeepLink(bitcoinURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, bitcoinURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
+ app.retainDeepLink(bolt11URL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, bolt11URL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
+ app.retainDeepLink(giftURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, giftURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
+ app.retainDeepLink(lnurlURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { _ in
+ XCTFail("URLs that need the node must stay pending until LDK is running")
+ }
+ XCTAssertEqual(app.pendingDeepLinkURL, lnurlURL)
+
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: true) { routedURL in
+ XCTAssertEqual(routedURL, lnurlURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+ }
+}
diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift
index 43be620ac..28a32b1a8 100644
--- a/BitkitTests/PubkyProfileManagerTests.swift
+++ b/BitkitTests/PubkyProfileManagerTests.swift
@@ -46,6 +46,44 @@ final class PubkyProfileManagerTests: XCTestCase {
XCTAssertEqual(queryItems["x-error"], "bitkit://pubky-auth/error?nonce=12345678-1234-1234-1234-123456789ABC")
}
+ func testPubkyRingAuthURLBuilderCreatesRingSpecificHandoff() throws {
+ let authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw&relay=https%3A%2F%2Frelay.example&secret=test"
+ let callbackAuthUrl = try XCTUnwrap(PubkyRingAuthURLBuilder.addingCallbacks(to: authUrl))
+ let ringUrl = try XCTUnwrap(PubkyRingAuthURLBuilder.ringHandoffURL(from: callbackAuthUrl))
+ let components = try XCTUnwrap(URLComponents(url: ringUrl, resolvingAgainstBaseURL: false))
+ let queryItems = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
+ item.value.map { (item.name, $0) }
+ })
+
+ XCTAssertEqual(components.scheme, "pubkyring")
+ XCTAssertEqual(components.host, "signin")
+ XCTAssertEqual(components.path, "")
+ XCTAssertEqual(queryItems["caps"], "/pub/bitkit.to/:rw")
+ XCTAssertEqual(queryItems["relay"], "https://relay.example")
+ XCTAssertEqual(queryItems["secret"], "test")
+ XCTAssertEqual(queryItems["x-success"], PubkyRingAuthURLBuilder.successCallback)
+ XCTAssertEqual(queryItems["x-cancel"], PubkyRingAuthURLBuilder.cancelCallback)
+ XCTAssertEqual(queryItems["x-error"], PubkyRingAuthURLBuilder.errorCallback)
+ XCTAssertEqual(queryItems["x-source"], PubkyRingAuthURLBuilder.source)
+ }
+
+ func testPubkyRingAuthURLBuilderCreatesRingSpecificHandoffFromLegacyRootURL() throws {
+ let ringUrl = try XCTUnwrap(
+ PubkyRingAuthURLBuilder.ringHandoffURL(
+ from: "pubkyauth:///?caps=/pub/bitkit.to/:rw&relay=https%3A%2F%2Frelay.example&secret=test"
+ )
+ )
+ let components = try XCTUnwrap(URLComponents(url: ringUrl, resolvingAgainstBaseURL: false))
+
+ XCTAssertEqual(components.scheme, "pubkyring")
+ XCTAssertEqual(components.host, "signin")
+ XCTAssertEqual(components.path, "")
+ }
+
+ func testPubkyRingAuthURLBuilderRejectsOtherSchemes() {
+ XCTAssertNil(PubkyRingAuthURLBuilder.ringHandoffURL(from: "bitkit://pubky-auth/success"))
+ }
+
func testPubkyRingAuthCallbackParsesNonce() throws {
XCTAssertEqual(
try PubkyRingAuthCallback.parse(url: XCTUnwrap(URL(string: "bitkit://pubky-auth/error?nonce=abc&errorMessage=Denied"))),
diff --git a/BitkitTests/SceneDelegateTests.swift b/BitkitTests/SceneDelegateTests.swift
new file mode 100644
index 000000000..7a18676e8
--- /dev/null
+++ b/BitkitTests/SceneDelegateTests.swift
@@ -0,0 +1,25 @@
+@testable import Bitkit
+import XCTest
+
+final class SceneDelegateTests: XCTestCase {
+ func testForwardsDeepLinksToSwiftUIRetentionPath() throws {
+ let delegate = SceneDelegate()
+ let url = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=example"))
+ _ = DeepLinkRouter.shared.consume()
+ let forwarded = expectation(description: "deep link forwarded")
+ let observer = NotificationCenter.default.addObserver(
+ forName: .deepLinkReceived,
+ object: nil,
+ queue: nil
+ ) { notification in
+ XCTAssertEqual(notification.object as? URL, url)
+ forwarded.fulfill()
+ }
+ defer { NotificationCenter.default.removeObserver(observer) }
+
+ delegate.forwardDeepLink(url)
+
+ wait(for: [forwarded], timeout: 1)
+ XCTAssertEqual(DeepLinkRouter.shared.consume(), url)
+ }
+}
diff --git a/changelog.d/next/722.added.md b/changelog.d/next/722.added.md
new file mode 100644
index 000000000..1a656a8d9
--- /dev/null
+++ b/changelog.d/next/722.added.md
@@ -0,0 +1 @@
+Bitkit now opens Pubky marketplace setup links directly into explicit watch-only account consent.
diff --git a/journeys/README.md b/journeys/README.md
index 905af7d60..f40e90d50 100644
--- a/journeys/README.md
+++ b/journeys/README.md
@@ -135,13 +135,14 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount*
| [notification-permission](notification-permission) | 4 | Background-setup toggles |
| [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android |
| [hardware-wallet](hardware-wallet) | 15 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` |
+| [pubky-auth](pubky-auth) | 1 | Bitkit-specific OS handoff into watch-only consent; local Pubky identity required |
## Not ported
**`deeplinks` (2 journeys).** The Android journeys exercise `bitkit://screen/...` routing with a
dev-mode gate and a cold-start replay. iOS registers the `bitkit` URL scheme (`Bitkit/Info.plist`)
-but `onOpenURL` in `Bitkit/MainNavView.swift` only handles web URLs, Pubky auth callbacks and
-payment URIs — there is no screen or sheet deeplink router, and no dev-mode gate to test. These
+and retains external URLs in `AppScene`, but `MainNavView` only routes web URLs, Pubky auth requests and callbacks,
+and payment URIs — there is no screen or sheet deeplink router, and no dev-mode gate to test. These
journeys are blocked on the feature existing, not on the harness.
## Porting from Android
diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md
new file mode 100644
index 000000000..8ea0c7173
--- /dev/null
+++ b/journeys/pubky-auth/README.md
@@ -0,0 +1,14 @@
+# Pubky auth
+
+This suite covers the uniquely targetable `bitkit://pubky-auth/setup` OS handoff into Bitkit. Raw `pubkyauth` setup requests remain supported through QR scanning and clipboard paste for compatibility with the Pubky protocol.
+It stops at explicit watch-only consent and never authorizes or exports account material.
+Bitkit retains links delivered during startup, restoration, or PIN entry and presents consent only after the main wallet UI is available.
+
+## Preconditions
+
+- Build and run Bitkit with `E2E_BUILD`.
+- Complete wallet onboarding.
+- Enable Paykit UI in developer settings.
+- Create a Pubky profile in Bitkit so the wallet has a local identity secret.
+
+The journey uses a syntactically valid dummy request and does not contact its relay unless the authorization flow is completed.
diff --git a/journeys/pubky-auth/open-watch-only-link.xml b/journeys/pubky-auth/open-watch-only-link.xml
new file mode 100644
index 000000000..90ec43130
--- /dev/null
+++ b/journeys/pubky-auth/open-watch-only-link.xml
@@ -0,0 +1,14 @@
+
+ Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky identity. This journey launches the terminated app with a local-only dummy setup request and cancels before account material is exported.
+
+ Run `xcrun simctl terminate <UDID> to.bitkit`
+ Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1"`
+ If the simulator asks to open the link in Bitkit, tap Open
+ Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is visible
+ Tap Cancel (id "PubkyAuthWatchOnlyCancel")
+ Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is no longer visible
+ Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=unsupported-v1"`
+ Verify the invalid request toast (id "PubkyAuthInvalidRequestToast") is visible
+ Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is not visible
+
+