From 9de10730cb831faa7be243f5e07499f4d5004bac Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 7 Sep 2026 10:54:42 -0300 Subject: [PATCH 01/21] fix: release the navigation lock in on broadcast error --- Bitkit/ViewModels/HwFundingSigner.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 2e69de97e..fd8d4e0f6 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -490,10 +490,10 @@ final class HwSendCoordinator { await afterBroadcast(result) return result } catch { + isBroadcastUnresolved = false let outcomeIsUncertain = (error as? HwTransferError) == .broadcastUncertain if !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { pendingPayment = nil - isBroadcastUnresolved = false } throw error } From 3fcf1e1bef08c19c542cd660c8a47a81354e542b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 7 Sep 2026 11:03:50 -0300 Subject: [PATCH 02/21] fix: display specific error message for broadcast error --- .../Localization/en.lproj/Localizable.strings | 2 ++ Bitkit/ViewModels/AppViewModel.swift | 4 ++-- Bitkit/Views/Wallets/Send/HwSendSignView.swift | 16 ++++++++++------ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 634feaa4b..2344930f8 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -81,6 +81,8 @@ "hardware__remove_dialog_keep" = "Back up name and tags"; "hardware__remove_error" = "Could not remove the hardware wallet. Please try again."; "hardware__remove_keep_error" = "Could not keep this wallet's tags in your backup. Try again, or remove it without keeping them."; +"hardware__send_broadcast_failed_text" = "Check your connection and try again."; +"hardware__send_broadcast_failed_title" = "Payment not confirmed"; "hardware__send_confirm_address" = "To address (confirm on device)"; "hardware__send_open_connect" = "Open Trezor Connect"; "hardware__send_sign_title" = "Sign With Device"; diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 4fb599759..f9470eba6 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -344,8 +344,8 @@ extension AppViewModel { case .broadcastConnectivity: toast( type: .warning, - title: t("other__connection_issue"), - description: t("other__connection_issue_explain") + title: t("hardware__send_broadcast_failed_title"), + description: t("hardware__send_broadcast_failed_text") ) case .deviceBusy: toast(type: .info, title: t("hardware__device_busy")) diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 1b72c89ff..e5c65a9bf 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -78,7 +78,11 @@ struct HwSendSignView: View { signingTask = nil passphraseTask?.cancel() passphraseTask = nil + let droppedSignedTransaction = hwSend.hasPendingBroadcast hwSend.cancel() + if droppedSignedTransaction { + Task { await cancelContactPayment() } + } } .accessibilityElement(children: .contain) .accessibilityIdentifier("HardwareSendSign") @@ -126,23 +130,23 @@ struct HwSendSignView: View { hwSend.completeBroadcast() navigationPath.append(.success(paymentId: result.txId, walletId: walletId)) } catch is CancellationError { - await cancelContactPaymentIfBroadcastIsResolved() + await cancelContactPaymentIfBroadcastIsRetryable() return } catch is HwPassphraseError { - await cancelContactPaymentIfBroadcastIsResolved() + await cancelContactPaymentIfBroadcastIsRetryable() hwSend.requestPassphrase() } catch let error as HwTransferError { - await cancelContactPaymentIfBroadcastIsResolved() + await cancelContactPaymentIfBroadcastIsRetryable() app.toast(error) } catch { - await cancelContactPaymentIfBroadcastIsResolved() + await cancelContactPaymentIfBroadcastIsRetryable() showHardwareError(error) } } } - private func cancelContactPaymentIfBroadcastIsResolved() async { - guard !hwSend.isBroadcastUnresolved else { return } + private func cancelContactPaymentIfBroadcastIsRetryable() async { + guard !hwSend.hasPendingBroadcast else { return } await cancelContactPayment() } From 0ddab5b43a3e907e2b8a05cb1ac74cbd5e5606d5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 7 Sep 2026 11:10:20 -0300 Subject: [PATCH 03/21] test: broadcast error tests --- BitkitTests/HwFundingSignerTests.swift | 47 +++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index 22cc81415..633254a66 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -187,6 +187,50 @@ final class HwFundingSignerTests: XCTestCase { ) } + func testCoordinatorCancelDropsSignedPaymentAfterFailedBroadcast() async throws { + let funding = MockHwFunding() + let connecting = MockHwConnecting() + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + walletId: "trezor:wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, + connecting: connecting, + feeRate: satsPerVByte, + address: address + ) + } + ) + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") + + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2 + ) + } + + XCTAssertTrue(coordinator.hasPendingBroadcast) + + coordinator.cancel() + + XCTAssertFalse(coordinator.hasPendingBroadcast) + + funding.broadcastError = nil + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2 + ) + + XCTAssertEqual(funding.signCalls, 2) + XCTAssertEqual(funding.broadcastCalls, 2) + } + private func assertCoordinatorRetryReusesSignedPayment(error: Error) async throws { let funding = MockHwFunding() let connecting = MockHwConnecting() @@ -218,7 +262,8 @@ final class HwFundingSignerTests: XCTestCase { } XCTAssertTrue(coordinator.hasPendingBroadcast) - XCTAssertTrue(coordinator.isBroadcastUnresolved) + XCTAssertFalse(coordinator.isBroadcastUnresolved) + XCTAssertFalse(coordinator.isSigning) funding.broadcastError = nil _ = try await coordinator.signAndBroadcast( From 94690d595abf3139e262ec5336a91208f1f6a432 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 7 Sep 2026 11:14:09 -0300 Subject: [PATCH 04/21] chore: add changelog fragment Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J1dzLxvzqbhAMSTyXamczc --- changelog.d/next/hw-send-broadcast-lockout.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/hw-send-broadcast-lockout.fixed.md diff --git a/changelog.d/next/hw-send-broadcast-lockout.fixed.md b/changelog.d/next/hw-send-broadcast-lockout.fixed.md new file mode 100644 index 000000000..d3c0ba9e2 --- /dev/null +++ b/changelog.d/next/hw-send-broadcast-lockout.fixed.md @@ -0,0 +1 @@ +A hardware-wallet payment that fails to broadcast now tells you the payment was not confirmed and lets you retry or leave, instead of locking the send screen. From 1b9ffc08651fac23527a2f0ac4c932901fd60b4e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 7 Sep 2026 11:17:36 -0300 Subject: [PATCH 05/21] chore: rename changelog fragment Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J1dzLxvzqbhAMSTyXamczc --- .../next/{hw-send-broadcast-lockout.fixed.md => 729.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{hw-send-broadcast-lockout.fixed.md => 729.fixed.md} (100%) diff --git a/changelog.d/next/hw-send-broadcast-lockout.fixed.md b/changelog.d/next/729.fixed.md similarity index 100% rename from changelog.d/next/hw-send-broadcast-lockout.fixed.md rename to changelog.d/next/729.fixed.md From 505da8dd49cc767d273ca40ee2d0dfbac015ccd6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 7 Sep 2026 14:44:23 -0300 Subject: [PATCH 06/21] fix: keep the payment proof for an unresolved broadcast An uncertain broadcast may still confirm, so discarding the signed transaction on the way out must not delete the proof prepared for it. Leaving the pending row behind is recoverable; deleting it is not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J1dzLxvzqbhAMSTyXamczc --- Bitkit/Views/Wallets/Send/HwSendSignView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index e5c65a9bf..818decb0b 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -78,11 +78,7 @@ struct HwSendSignView: View { signingTask = nil passphraseTask?.cancel() passphraseTask = nil - let droppedSignedTransaction = hwSend.hasPendingBroadcast hwSend.cancel() - if droppedSignedTransaction { - Task { await cancelContactPayment() } - } } .accessibilityElement(children: .contain) .accessibilityIdentifier("HardwareSendSign") From ae4b58465f323650deaff668f44bfc2ec0ed5ad3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:17:43 +0200 Subject: [PATCH 07/21] feat: open Pubky auth links --- Bitkit/Info.plist | 1 + Bitkit/ViewModels/AppViewModel.swift | 6 +++++- BitkitTests/PubkyAuthURLSchemeTests.swift | 11 +++++++++++ changelog.d/next/715.added.md | 1 + journeys/README.md | 5 +++-- journeys/pubky-auth/README.md | 12 ++++++++++++ journeys/pubky-auth/open-watch-only-link.xml | 13 +++++++++++++ 7 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 BitkitTests/PubkyAuthURLSchemeTests.swift create mode 100644 changelog.d/next/715.added.md create mode 100644 journeys/pubky-auth/README.md create mode 100644 journeys/pubky-auth/open-watch-only-link.xml diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 020553009..1a3532cdc 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -8,6 +8,7 @@ CFBundleURLSchemes bitkit + pubkyauth bitcoin BITCOIN lightning diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f9470eba6..d7a7d65f2 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -807,7 +807,11 @@ extension AppViewModel { 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")) + toast( + type: .error, + title: t("pubky_auth__invalid_request"), + accessibilityIdentifier: "PubkyAuthInvalidRequestToast" + ) } } diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift new file mode 100644 index 000000000..46830d8b3 --- /dev/null +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -0,0 +1,11 @@ +@testable import Bitkit +import XCTest + +final class PubkyAuthURLSchemeTests: XCTestCase { + func testAppRegistersPubkyAuthAsInboundURLScheme() throws { + let urlTypes = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]]) + let schemes = urlTypes.flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } + + XCTAssertTrue(schemes.contains("pubkyauth")) + } +} diff --git a/changelog.d/next/715.added.md b/changelog.d/next/715.added.md new file mode 100644 index 000000000..1a656a8d9 --- /dev/null +++ b/changelog.d/next/715.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..68630c7c8 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 | 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 +but `onOpenURL` in `Bitkit/MainNavView.swift` only handles 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..2dbd8b4db --- /dev/null +++ b/journeys/pubky-auth/README.md @@ -0,0 +1,12 @@ +# Pubky auth + +This suite covers the OS handoff into Bitkit for `pubkyauth` setup links. It stops at explicit watch-only consent and never authorizes or exports account material. + +## 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..2ecffe233 --- /dev/null +++ b/journeys/pubky-auth/open-watch-only-link.xml @@ -0,0 +1,13 @@ + + Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky identity. This journey opens a local-only dummy setup request and cancels before account material is exported. + + Run `xcrun simctl openurl <UDID> "pubkyauth://signin?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> "pubkyauth://signin?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 + + From 9dac17b6bf92a1f25808dfc056a4280f5bbb4f5f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:23:53 +0200 Subject: [PATCH 08/21] fix: preserve Pubky Ring handoff --- Bitkit/Info.plist | 2 +- Bitkit/Managers/PubkyProfileManager.swift | 15 +++++++-- BitkitTests/PubkyAuthURLSchemeTests.swift | 6 ++++ BitkitTests/PubkyProfileManagerTests.swift | 38 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 1a3532cdc..f01443798 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -38,7 +38,7 @@ $(TREZOR_ELECTRUM_URL) LSApplicationQueriesSchemes - pubkyauth + pubkyring NSAppTransportSecurity 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/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 46830d8b3..1c8f5ea43 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -8,4 +8,10 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertTrue(schemes.contains("pubkyauth")) } + + func testAppQueriesPubkyRingSpecificOutboundURLScheme() throws { + let schemes = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "LSApplicationQueriesSchemes") as? [String]) + + XCTAssertTrue(schemes.contains("pubkyring")) + } } 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"))), From d24cb4039ccf03107cc0d17264a30296b87915d7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:25:18 +0200 Subject: [PATCH 09/21] chore: rename changelog fragment --- changelog.d/next/{715.added.md => 722.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{715.added.md => 722.added.md} (100%) diff --git a/changelog.d/next/715.added.md b/changelog.d/next/722.added.md similarity index 100% rename from changelog.d/next/715.added.md rename to changelog.d/next/722.added.md From 6ce8c657d31cf23b122b86f6c09b18dea8ace3ab Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:57:26 +0200 Subject: [PATCH 10/21] fix: retain deep links through startup --- Bitkit/AppScene.swift | 1 + Bitkit/MainNavView.swift | 134 ++++++++++--------- Bitkit/ViewModels/AppViewModel.swift | 11 ++ BitkitTests/PubkyAuthURLSchemeTests.swift | 13 ++ journeys/pubky-auth/README.md | 1 + journeys/pubky-auth/open-watch-only-link.xml | 3 +- 6 files changed, 99 insertions(+), 64 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ab8f05a7f..5a37a5d9f 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() } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index a1b201357..d136b1a0c 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -317,69 +317,12 @@ 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 { + await handlePendingDeepLink() + } + .onChange(of: app.pendingDeepLinkURL) { _, url in + guard url != nil else { return } + Task { await handlePendingDeepLink() } } .alert( t("other__clipboard_redirect_title"), @@ -698,6 +641,71 @@ struct MainNavView: View { !SamRockSetupRequest.isProtocolURL(uri) && !PubkyAuthRequest.isProtocolURL(uri) } + private func handlePendingDeepLink() async { + guard let url = app.takePendingDeepLink() else { return } + + 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/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index d7a7d65f2..306afa417 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,15 @@ class AppViewModel: ObservableObject { appStatusInit = true } + func retainDeepLink(_ url: URL) { + pendingDeepLinkURL = url + } + + func takePendingDeepLink() -> URL? { + defer { pendingDeepLinkURL = nil } + return pendingDeepLinkURL + } + private let lightningService: LightningService private let coreService: CoreService private let sheetViewModel: SheetViewModel diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 1c8f5ea43..02f73aa03 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -14,4 +14,17 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertTrue(schemes.contains("pubkyring")) } + + @MainActor + func testAppRetainsPubkyAuthURLUntilMainNavigationConsumesIt() throws { + let app = AppViewModel() + let url = try XCTUnwrap(URL(string: "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1")) + + app.retainDeepLink(url) + + XCTAssertEqual(app.pendingDeepLinkURL, url) + XCTAssertEqual(app.takePendingDeepLink(), url) + XCTAssertNil(app.pendingDeepLinkURL) + XCTAssertNil(app.takePendingDeepLink()) + } } diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index 2dbd8b4db..e8fe2a4ad 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -1,6 +1,7 @@ # Pubky auth This suite covers the OS handoff into Bitkit for `pubkyauth` setup links. 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 diff --git a/journeys/pubky-auth/open-watch-only-link.xml b/journeys/pubky-auth/open-watch-only-link.xml index 2ecffe233..de8706f63 100644 --- a/journeys/pubky-auth/open-watch-only-link.xml +++ b/journeys/pubky-auth/open-watch-only-link.xml @@ -1,6 +1,7 @@ - Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky identity. This journey opens a local-only dummy setup request and cancels before account material is exported. + 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> "pubkyauth://signin?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 From 816ad3867ff62255e9c34f7da265f732613ee160 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:23:55 +0200 Subject: [PATCH 11/21] fix: target Bitkit Pubky auth links uniquely --- Bitkit/AppScene.swift | 2 +- Bitkit/Info.plist | 1 - Bitkit/MainNavView.swift | 17 ++++- Bitkit/Models/PubkyAuthRequest.swift | 35 +++++++++-- Bitkit/ViewModels/AppViewModel.swift | 9 +-- BitkitTests/PubkyAuthRequestTests.swift | 45 ++++++++++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 65 +++++++++++++++++--- journeys/README.md | 4 +- journeys/pubky-auth/README.md | 3 +- journeys/pubky-auth/open-watch-only-link.xml | 4 +- 10 files changed, 159 insertions(+), 26 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5a37a5d9f..40c6737a1 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -403,7 +403,7 @@ struct AppScene: View { } ) } else { - MainNavView() + MainNavView(canHandleDeepLinks: wallet.nodeLifecycleState == .running) } } } diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index f01443798..5e1652411 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -8,7 +8,6 @@ CFBundleURLSchemes bitkit - pubkyauth bitcoin BITCOIN lightning diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index d136b1a0c..21f989eb1 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,11 +323,12 @@ struct MainNavView: View { notificationManager.unregister() } } - .task { + .task(id: canHandleDeepLinks) { + guard canHandleDeepLinks else { return } await handlePendingDeepLink() } .onChange(of: app.pendingDeepLinkURL) { _, url in - guard url != nil else { return } + guard canHandleDeepLinks, url != nil else { return } Task { await handlePendingDeepLink() } } .alert( @@ -642,8 +649,12 @@ struct MainNavView: View { } private func handlePendingDeepLink() async { - guard let url = app.takePendingDeepLink() else { return } + await app.routePendingDeepLinkIfReady(canHandleDeepLinks) { 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 diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 8b06f15c1..4f4e1116b 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -51,6 +51,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 @@ -60,20 +63,44 @@ struct PubkyAuthRequest { let bitkitClaim: PubkyAuthClaim? 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 let components = URLComponents(string: trimmedValue), + components.scheme?.lowercased() == "bitkit", + components.host?.lowercased() == bitkitSetupHost, + components.path == bitkitSetupPath, + components.user == nil, + components.password == nil, + components.port == nil + else { + return value + } + + guard let queryDelimiter = trimmedValue.firstIndex(of: "?") else { + return "pubkyauth://signin" + } + + let queryStart = trimmedValue.index(after: queryDelimiter) + let fragmentDelimiter = trimmedValue[queryStart...].firstIndex(of: "#") ?? trimmedValue.endIndex + return "pubkyauth://signin?\(trimmedValue[queryStart ..< fragmentDelimiter])" } static func parse(url: String) throws -> PubkyAuthRequest { - let details = try Paykit.parsePubkyAuthUrl(authUrl: url) + let normalizedURL = normalizedProtocolURL(url) + 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) return PubkyAuthRequest( - rawUrl: url, + rawUrl: normalizedURL, kind: details.kind, relay: details.relayUrl ?? "", capabilities: capabilities, diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 306afa417..9bf5603b0 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -121,9 +121,10 @@ class AppViewModel: ObservableObject { pendingDeepLinkURL = url } - func takePendingDeepLink() -> URL? { - defer { pendingDeepLinkURL = nil } - return pendingDeepLinkURL + func routePendingDeepLinkIfReady(_ isReady: Bool, handler: (URL) async -> Void) async { + guard isReady, let url = pendingDeepLinkURL else { return } + pendingDeepLinkURL = nil + await handler(url) } private let lightningService: LightningService @@ -447,7 +448,7 @@ extension AppViewModel { } } - let uri = uri.removingLightningSchemes() + let uri = PubkyAuthRequest.normalizedProtocolURL(uri.removingLightningSchemes()) let prevalidatedPaymentRequest: BitkitCore.Scanner? if scope == .paymentRequests { guard SamRockSetupRequest.parse(uri) == nil, diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index b1b4af240..46ed9f8bb 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -13,6 +13,51 @@ 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 testProtocolUrlDoesNotTreatPubkyRingCallbackAsSetupHandoff() { + let url = "bitkit://pubky-auth/success?nonce=123" + + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + } + + func testProtocolUrlPreservesEncodedQueryOrderAndDropsFragment() { + let query = "caps=a%2Fb&relay=https%3A%2F%2Fx&secret=first&secret=second" + + XCTAssertEqual( + PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)#ignored"), + "pubkyauth://signin?\(query)" + ) + } + + func testProtocolUrlDoesNotReserializeRawQueryBytes() { + let query = "caps=&relay=https%3A%2F%2Fx&secret=first&secret=second" + + XCTAssertEqual( + PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)#ignored"), + "pubkyauth://signin?\(query)" + ) + } + + func testProtocolUrlRejectsBitkitSetupHandoffWithoutQuery() { + let url = "bitkit://pubky-auth/setup" + + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(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" diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 02f73aa03..f39e9dbd0 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -2,11 +2,12 @@ import XCTest final class PubkyAuthURLSchemeTests: XCTestCase { - func testAppRegistersPubkyAuthAsInboundURLScheme() throws { + 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("pubkyauth")) + XCTAssertTrue(schemes.contains("bitkit")) + XCTAssertFalse(schemes.contains("pubkyauth")) } func testAppQueriesPubkyRingSpecificOutboundURLScheme() throws { @@ -16,15 +17,63 @@ final class PubkyAuthURLSchemeTests: XCTestCase { } @MainActor - func testAppRetainsPubkyAuthURLUntilMainNavigationConsumesIt() throws { - let app = AppViewModel() - let url = try XCTUnwrap(URL(string: "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1")) + 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(app.pendingDeepLinkURL, url) - XCTAssertEqual(app.takePendingDeepLink(), url) + XCTAssertEqual(routeCount, 1) XCTAssertNil(app.pendingDeepLinkURL) - XCTAssertNil(app.takePendingDeepLink()) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .pubkyAuthApproval) + let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? PubkyAuthApprovalConfig) + XCTAssertEqual(config.request.bitkitClaim, .watchOnlyAccountV1) } } diff --git a/journeys/README.md b/journeys/README.md index 68630c7c8..f40e90d50 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -135,13 +135,13 @@ 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 | OS handoff into watch-only consent; local Pubky identity required | +| [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 requests and callbacks, +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. diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index e8fe2a4ad..8ea0c7173 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -1,6 +1,7 @@ # Pubky auth -This suite covers the OS handoff into Bitkit for `pubkyauth` setup links. It stops at explicit watch-only consent and never authorizes or exports account material. +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 diff --git a/journeys/pubky-auth/open-watch-only-link.xml b/journeys/pubky-auth/open-watch-only-link.xml index de8706f63..90ec43130 100644 --- a/journeys/pubky-auth/open-watch-only-link.xml +++ b/journeys/pubky-auth/open-watch-only-link.xml @@ -2,12 +2,12 @@ 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> "pubkyauth://signin?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"` + 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> "pubkyauth://signin?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"` + 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 From b1bd510e25b01f1684558c78f1825872a41008eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:34:36 +0200 Subject: [PATCH 12/21] fix: reject malformed Pubky handoff fragments --- Bitkit/Models/PubkyAuthRequest.swift | 4 ++-- BitkitTests/PubkyAuthRequestTests.swift | 29 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 4f4e1116b..4c3cb5fb2 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -80,12 +80,12 @@ struct PubkyAuthRequest { return value } - guard let queryDelimiter = trimmedValue.firstIndex(of: "?") else { + let fragmentDelimiter = trimmedValue.firstIndex(of: "#") ?? trimmedValue.endIndex + guard let queryDelimiter = trimmedValue[.. Date: Wed, 2 Sep 2026 03:50:35 +0200 Subject: [PATCH 13/21] fix: preserve Pubky wrapper validation --- Bitkit/Models/PubkyAuthRequest.swift | 46 +++++++++++++++-------- Bitkit/ViewModels/AppViewModel.swift | 13 ++++--- BitkitTests/PubkyAuthRequestTests.swift | 43 ++++++++++++++++----- BitkitTests/PubkyAuthURLSchemeTests.swift | 9 +++++ 4 files changed, 79 insertions(+), 32 deletions(-) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 4c3cb5fb2..d1f3e5e6d 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -69,27 +69,18 @@ struct PubkyAuthRequest { /// 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 let components = URLComponents(string: trimmedValue), - components.scheme?.lowercased() == "bitkit", - components.host?.lowercased() == bitkitSetupHost, - components.path == bitkitSetupPath, - components.user == nil, - components.password == nil, - components.port == nil + guard isBitkitSetupHandoff(trimmedValue), + let queryDelimiter = trimmedValue.firstIndex(of: "?") else { return value } - let fragmentDelimiter = trimmedValue.firstIndex(of: "#") ?? trimmedValue.endIndex - guard let queryDelimiter = trimmedValue[.. PubkyAuthRequest { + let requiresBitkitClaim = isBitkitSetupHandoff(url.trimmingCharacters(in: .whitespacesAndNewlines)) let normalizedURL = normalizedProtocolURL(url) let details = try Paykit.parsePubkyAuthUrl(authUrl: normalizedURL) let capabilities = details.capabilities ?? "" @@ -98,7 +89,11 @@ struct PubkyAuthRequest { let serviceNames = permissions .compactMap { extractServiceName($0.path) } .filter { seenServiceNames.insert($0).inserted } - let bitkitClaim = try parseBitkitClaim(url: normalizedURL, capabilities: capabilities) + let bitkitClaim = try parseBitkitClaim( + url: normalizedURL, + capabilities: capabilities, + requiresBitkitClaim: requiresBitkitClaim + ) return PubkyAuthRequest( rawUrl: normalizedURL, kind: details.kind, @@ -110,7 +105,7 @@ struct PubkyAuthRequest { ) } - static func parseBitkitClaim(url: String, capabilities: String) throws -> PubkyAuthClaim? { + static func parseBitkitClaim(url: String, capabilities: String, requiresBitkitClaim: Bool = false) throws -> PubkyAuthClaim? { guard let components = URLComponents(string: url) else { throw PubkyAuthRequestError.invalidUrl } @@ -123,7 +118,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 @@ -138,6 +133,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/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 9bf5603b0..5671b8348 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -448,7 +448,8 @@ extension AppViewModel { } } - let uri = PubkyAuthRequest.normalizedProtocolURL(uri.removingLightningSchemes()) + let sourceURI = uri.removingLightningSchemes() + let uri = PubkyAuthRequest.normalizedProtocolURL(sourceURI) let prevalidatedPaymentRequest: BitkitCore.Scanner? if scope == .paymentRequests { guard SamRockSetupRequest.parse(uri) == nil, @@ -661,7 +662,7 @@ extension AppViewModel { } handleNodeUri(url) - case let .pubkyAuth(data: authUrl): + case .pubkyAuth: guard PaykitFeatureFlags.isUIEnabled else { toast( type: .error, @@ -671,7 +672,7 @@ extension AppViewModel { ) return } - handlePubkyAuthApproval(authUrl) + handlePubkyAuthApproval(sourceURI) case let .gift(code, amount): sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount))) default: @@ -798,7 +799,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")) @@ -815,8 +816,8 @@ 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( diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 052716ac6..5449dcb5a 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -35,6 +35,14 @@ final class PubkyAuthRequestTests: XCTestCase { } } + 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" @@ -55,20 +63,20 @@ final class PubkyAuthRequestTests: XCTestCase { } } - func testProtocolUrlPreservesEncodedQueryOrderAndDropsFragment() { + func testProtocolUrlRejectsFragment() { let query = "caps=a%2Fb&relay=https%3A%2F%2Fx&secret=first&secret=second" + let url = "bitkit://pubky-auth/setup?\(query)#ignored" - XCTAssertEqual( - PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)#ignored"), - "pubkyauth://signin?\(query)" - ) + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) } - func testProtocolUrlDoesNotReserializeRawQueryBytes() { - let query = "caps=&relay=https%3A%2F%2Fx&secret=first&secret=second" + func testProtocolUrlDoesNotReserializeRawOrEncodedQueryBytes() { + let query = "caps=%23encoded&relay=https%3A%2F%2Fx&secret=first&secret=second" XCTAssertEqual( - PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)#ignored"), + PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)"), "pubkyauth://signin?\(query)" ) } @@ -76,14 +84,29 @@ final class PubkyAuthRequestTests: XCTestCase { func testProtocolUrlRejectsBitkitSetupHandoffWithoutQuery() { let url = "bitkit://pubky-auth/setup" - XCTAssertTrue(PubkyAuthRequest.isProtocolURL(url)) + 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=" - XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), "pubkyauth://signin") + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) } diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index f39e9dbd0..86942cf94 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -75,5 +75,14 @@ final class PubkyAuthURLSchemeTests: XCTestCase { 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) } } From 2687f89877631054b0ed9ca7b106c491b0e382c1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 23:43:18 +0200 Subject: [PATCH 14/21] fix: ungated Pubky links; reject dupes --- Bitkit/AppScene.swift | 2 +- Bitkit/MainNavView.swift | 7 ++- Bitkit/Models/PubkyAuthRequest.swift | 13 ++++++ Bitkit/ViewModels/AppViewModel.swift | 15 ++++++- BitkitTests/PubkyAuthRequestTests.swift | 16 +++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 52 +++++++++++++++++++++++ 6 files changed, 101 insertions(+), 4 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 40c6737a1..5a37a5d9f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -403,7 +403,7 @@ struct AppScene: View { } ) } else { - MainNavView(canHandleDeepLinks: wallet.nodeLifecycleState == .running) + MainNavView() } } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 21f989eb1..da674bfa3 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -323,7 +323,7 @@ struct MainNavView: View { notificationManager.unregister() } } - .task(id: canHandleDeepLinks) { + .task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running]) { guard canHandleDeepLinks else { return } await handlePendingDeepLink() } @@ -649,7 +649,10 @@ struct MainNavView: View { } private func handlePendingDeepLink() async { - await app.routePendingDeepLinkIfReady(canHandleDeepLinks) { url in + await app.routePendingDeepLinkIfReady( + canHandleDeepLinks, + nodeIsRunning: wallet.nodeLifecycleState == .running + ) { url in await handleDeepLink(url) } } diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index d1f3e5e6d..7298b32a4 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 } @@ -82,6 +84,7 @@ struct PubkyAuthRequest { static func parse(url: String) throws -> PubkyAuthRequest { 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) @@ -105,6 +108,16 @@ struct PubkyAuthRequest { ) } + 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 diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5671b8348..5bd7c549b 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -121,12 +121,25 @@ class AppViewModel: ObservableObject { pendingDeepLinkURL = url } - func routePendingDeepLinkIfReady(_ isReady: Bool, handler: (URL) async -> Void) async { + 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 + } + return !PubkyAuthRequest.isProtocolURL(url.absoluteString) + } + private let lightningService: LightningService private let coreService: CoreService private let sheetViewModel: SheetViewModel diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 5449dcb5a..aba6076fe 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -194,6 +194,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 index 86942cf94..575784c6b 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -84,5 +84,57 @@ final class PubkyAuthURLSchemeTests: XCTestCase { 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 lightningURL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) + + 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(lightningURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { _ in + XCTFail("URLs that need the node must stay pending until LDK is running") + } + XCTAssertEqual(app.pendingDeepLinkURL, lightningURL) + + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: true) { routedURL in + XCTAssertEqual(routedURL, lightningURL) + } + XCTAssertNil(app.pendingDeepLinkURL) } } From a801819bce2d7bf4b582e9e46b1f7a291a9b8b83 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 3 Sep 2026 23:51:34 +0200 Subject: [PATCH 15/21] fix: classify setup routes before node --- Bitkit/ViewModels/AppViewModel.swift | 9 ++++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 25 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5bd7c549b..af26db622 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -137,6 +137,15 @@ class AppViewModel: ObservableObject { 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 + } return !PubkyAuthRequest.isProtocolURL(url.absoluteString) } diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 575784c6b..4c37b4725 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -106,6 +106,13 @@ final class PubkyAuthURLSchemeTests: XCTestCase { "&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 lightningURL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) app.retainDeepLink(pubkyURL) @@ -126,6 +133,24 @@ final class PubkyAuthURLSchemeTests: XCTestCase { } 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(lightningURL) await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { _ in XCTFail("URLs that need the node must stay pending until LDK is running") From 4b07f9727ff844e728158b3ca617d07a841c966d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 4 Sep 2026 00:45:02 +0200 Subject: [PATCH 16/21] fix: ungated BIP21 and BOLT11 links --- Bitkit/ViewModels/AppViewModel.swift | 11 +++++++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 22 ++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index af26db622..f398d4586 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -146,9 +146,20 @@ class AppViewModel: ObservableObject { if SamRockSetupRequest.isProtocolURL(url.absoluteString) { return false } + if url.scheme?.lowercased() == "bitcoin" { + return false + } + if isBolt11Invoice(url) { + 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 diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 4c37b4725..0294192d9 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -113,7 +113,9 @@ final class PubkyAuthURLSchemeTests: XCTestCase { let lnurlSamRockURL = try XCTUnwrap( URL(string: "lnurl:https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123") ) - let lightningURL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) + let bitcoinURL = try XCTUnwrap(URL(string: "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001")) + let bolt11URL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) + let lnurlURL = try XCTUnwrap(URL(string: "lnurl:lnurl1example")) app.retainDeepLink(pubkyURL) await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in @@ -151,14 +153,26 @@ final class PubkyAuthURLSchemeTests: XCTestCase { } XCTAssertNil(app.pendingDeepLinkURL) - app.retainDeepLink(lightningURL) + 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(lnurlURL) await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { _ in XCTFail("URLs that need the node must stay pending until LDK is running") } - XCTAssertEqual(app.pendingDeepLinkURL, lightningURL) + XCTAssertEqual(app.pendingDeepLinkURL, lnurlURL) await app.routePendingDeepLinkIfReady(true, nodeIsRunning: true) { routedURL in - XCTAssertEqual(routedURL, lightningURL) + XCTAssertEqual(routedURL, lnurlURL) } XCTAssertNil(app.pendingDeepLinkURL) } From 69025cdb65a51cc13993b7f1cf63cdcc7fb4612d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 03:53:42 +0200 Subject: [PATCH 17/21] fix: route gifts and identify auth relay --- Bitkit/Models/PubkyAuthRequest.swift | 15 +++++++++++ .../Localization/en.lproj/Localizable.strings | 2 ++ Bitkit/ViewModels/AppViewModel.swift | 5 ++++ .../PubkyAuthApprovalSheet.swift | 26 ++++++++++++++++++- BitkitTests/PubkyAuthRequestTests.swift | 9 +++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 7 +++++ 6 files changed, 63 insertions(+), 1 deletion(-) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 7298b32a4..a63d00bf3 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -64,6 +64,21 @@ 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: normalizedProtocolURL(value).trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth" } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 2344930f8..de9fd1c0d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -701,10 +701,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/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f398d4586..fea0add42 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -152,6 +152,11 @@ class AppViewModel: ObservableObject { if isBolt11Invoice(url) { return false } + if url.scheme?.lowercased() == "bitkit", + url.host?.lowercased().hasPrefix("gift-") == true + { + return false + } return !PubkyAuthRequest.isProtocolURL(url.absoluteString) } 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 aba6076fe..583172db0 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -26,6 +26,15 @@ final class PubkyAuthRequestTests: XCTestCase { 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)" diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 0294192d9..49b97f8f7 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -115,6 +115,7 @@ final class PubkyAuthURLSchemeTests: XCTestCase { ) 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) @@ -165,6 +166,12 @@ final class PubkyAuthURLSchemeTests: XCTestCase { } 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") From 02eb8d49790216870f1c1747f8ceaafb8568b43a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 04:08:39 +0200 Subject: [PATCH 18/21] fix: retain scene-delivered deep links --- Bitkit/AppScene.swift | 21 ++++++++++++++++++ Bitkit/BitkitApp.swift | 1 + Bitkit/SceneDelegate.swift | 32 ++++++++++++++++++++++++++++ BitkitTests/SceneDelegateTests.swift | 25 ++++++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 BitkitTests/SceneDelegateTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5a37a5d9f..790311d36 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -284,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, @@ -292,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) @@ -302,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..86f857eef 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 { diff --git a/Bitkit/SceneDelegate.swift b/Bitkit/SceneDelegate.swift index 51e36c932..605aaace9 100644 --- a/Bitkit/SceneDelegate.swift +++ b/Bitkit/SceneDelegate.swift @@ -1,6 +1,21 @@ import SwiftUI import UIKit +final class DeepLinkRouter { + static let shared = DeepLinkRouter() + + private var pendingURL: URL? + + func retain(_ url: URL) { + pendingURL = 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 +23,7 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { // MARK: - Quick Action State var savedShortCutItem: UIApplicationShortcutItem? + var savedDeepLinkURL: URL? // MARK: - Scene Connection @@ -16,6 +32,7 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { if let shortcutItem = connectionOptions.shortcutItem { savedShortCutItem = shortcutItem } + savedDeepLinkURL = connectionOptions.urlContexts.first?.url } // MARK: - Scene Activation @@ -26,6 +43,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 +61,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 +74,9 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { let userInfo = ["shortcutType": shortcutItem.type] NotificationCenter.default.post(name: .quickActionSelected, object: nil, userInfo: userInfo) } + + func forwardDeepLink(_ url: URL) { + DeepLinkRouter.shared.retain(url) + NotificationCenter.default.post(name: .deepLinkReceived, object: url) + } } 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) + } +} From 89259161488a01495e6c2ad6ea68b9c75a52b712 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 04:12:09 +0200 Subject: [PATCH 19/21] fix: forward app-delivered deep links --- Bitkit/BitkitApp.swift | 9 +++++++++ Bitkit/SceneDelegate.swift | 8 ++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index 86f857eef..268d511bd 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -40,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/SceneDelegate.swift b/Bitkit/SceneDelegate.swift index 605aaace9..570d4e637 100644 --- a/Bitkit/SceneDelegate.swift +++ b/Bitkit/SceneDelegate.swift @@ -10,6 +10,11 @@ final class DeepLinkRouter { pendingURL = url } + func forward(_ url: URL) { + retain(url) + NotificationCenter.default.post(name: .deepLinkReceived, object: url) + } + func consume() -> URL? { defer { pendingURL = nil } return pendingURL @@ -76,7 +81,6 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { } func forwardDeepLink(_ url: URL) { - DeepLinkRouter.shared.retain(url) - NotificationCenter.default.post(name: .deepLinkReceived, object: url) + DeepLinkRouter.shared.forward(url) } } From 4d0a0b659e57fa53c1d9e62dc9822c57c9d3f188 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 14:56:06 +0200 Subject: [PATCH 20/21] chore: format deep-link files --- Bitkit/MainNavView.swift | 72 +++++++++++++++++++++++----- Bitkit/Models/PubkyAuthRequest.swift | 8 +++- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index da674bfa3..a805282eb 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -436,13 +436,29 @@ struct MainNavView: View { ContactsIntroView() } case .contactsIntro: - if isPaykitUIActive { ContactsIntroView() } else { ComingSoonScreen() } + if isPaykitUIActive { + ContactsIntroView() + } else { + ComingSoonScreen() + } case let .contactDetail(publicKey): - if isPaykitUIActive { ContactDetailView(publicKey: publicKey) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + ContactDetailView(publicKey: publicKey) + } else { + paykitDisabledRedirectView + } case let .contactSaved(publicKey): - if isPaykitUIActive { ContactDetailView(publicKey: publicKey, showsDeleteAction: true) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + ContactDetailView(publicKey: publicKey, showsDeleteAction: true) + } else { + paykitDisabledRedirectView + } case let .contactActivity(publicKey): - if isPaykitUIActive { ContactActivityView(publicKey: publicKey) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + ContactActivityView(publicKey: publicKey) + } else { + paykitDisabledRedirectView + } case let .assignActivityContact(activityId, walletId): if isPaykitUIActive { AssignActivityContactView(activityId: activityId, walletId: walletId) @@ -471,9 +487,17 @@ struct MainNavView: View { ContactImportSelectView(contacts: contactsManager.pendingImportContacts) } case let .addContact(publicKey): - if isPaykitUIActive { AddContactView(publicKey: publicKey) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + AddContactView(publicKey: publicKey) + } else { + paykitDisabledRedirectView + } case let .editContact(publicKey): - if isPaykitUIActive { EditContactView(publicKey: publicKey) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + EditContactView(publicKey: publicKey) + } else { + paykitDisabledRedirectView + } case .profile: if !isPaykitUIActive { ComingSoonScreen() @@ -489,17 +513,41 @@ struct MainNavView: View { ProfileIntroView() } case .profileIntro: - if isPaykitUIActive { ProfileIntroView() } else { ComingSoonScreen() } + if isPaykitUIActive { + ProfileIntroView() + } else { + ComingSoonScreen() + } case .pubkyChoice: - if isPaykitUIActive { PubkyChoiceView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + PubkyChoiceView() + } else { + paykitDisabledRedirectView + } case .createProfile: - if isPaykitUIActive { CreateProfileView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + CreateProfileView() + } else { + paykitDisabledRedirectView + } case .editProfile: - if isPaykitUIActive { EditProfileView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + EditProfileView() + } else { + paykitDisabledRedirectView + } case .payContacts: - if isPaykitUIActive { PayContactsView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + PayContactsView() + } else { + paykitDisabledRedirectView + } case .paymentRequests: - if isPaykitUIActive { PaymentRequestsView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + PaymentRequestsView() + } else { + paykitDisabledRedirectView + } // Shop case .shopIntro: ShopIntro() diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index a63d00bf3..dab512605 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -44,8 +44,12 @@ struct PubkyAuthPermission { var displayAccess: String { var levels: [String] = [] - if accessLevel.contains("r") { levels.append("READ") } - if accessLevel.contains("w") { levels.append("WRITE") } + if accessLevel.contains("r") { + levels.append("READ") + } + if accessLevel.contains("w") { + levels.append("WRITE") + } return levels.joined(separator: ", ") } } From c2de3f05610f6b586b7037bf7686db3c3a13c219 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 21:04:32 +0200 Subject: [PATCH 21/21] docs: clarify pubky link contract --- journeys/pubky-auth/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index e014c2704..dca0d0bd0 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -1,6 +1,6 @@ # Pubky auth -This suite covers the uniquely targetable `bitkit://pubky-auth/setup` OS handoff into Bitkit. The wrapper carries the Paykit grant-auth requester fields and normalizes to `pubkyauth://signin_grant`; raw Pubky auth and signup requests remain supported through QR scanning and clipboard paste. +This suite covers the uniquely targetable `bitkit://pubky-auth/setup` OS handoff into Bitkit. The wrapper carries the Paykit grant-auth requester fields, normalizes to `pubkyauth://signin_grant`, and is the only link form that receives Bitkit claim validation. `lightning:`/`lnurl*:`-prefixed raw `pubkyauth://` auth and signup requests are also accepted from OS links, matching scanner and clipboard-paste behavior. 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.