diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 35ab2455b..ca6b80a73 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1175,7 +1175,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc39"; + version = "0.1.0-rc43"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5af7be681..aa8c69e49 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "2fa056570bd5f93c166e43cf16c5356f6407cbbd", - "version" : "0.1.0-rc39" + "revision" : "6b241878a9bba5cecea919c0298c3f90624be6ff", + "version" : "0.1.0-rc43" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index bfc10b622..a48b22375 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -5,6 +5,7 @@ import UserNotifications struct AppScene: View { private static let paykitPaymentRequestRefreshIntervals: [Duration] = [.seconds(30), .seconds(60), .seconds(120)] + private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14) @Environment(\.scenePhase) var scenePhase @EnvironmentObject private var session: SessionManager @@ -38,6 +39,7 @@ struct AppScene: View { @State private var hwWalletManager: HwWalletManager @State private var calculatorInputManager = CalculatorInputManager() @State private var paykitPaymentRequestManager = PaykitPaymentRequestManager() + @State private var initialPaykitSyncGeneration = 0 @State private var hideSplash = false @State private var removeSplash = false @@ -138,6 +140,7 @@ struct AppScene: View { } .task(priority: .userInitiated, setupTask) .task(id: scenePhase) { await pollIncomingPaykitPaymentRequests() } + .task(id: initialPaykitSyncGeneration) { await pollIncomingPaykitPaymentRequestsDuringInitialSync() } .onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) } .onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) } .onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) } @@ -220,9 +223,18 @@ struct AppScene: View { let publicKeys = contacts.map(\.publicKey) Task { await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) + await PrivatePaykitService.shared.startInitialLinkBurst( + for: publicKeys, + savedPublicKeys: publicKeys, + wallet: wallet, + reason: "contact sync" + ) await refreshIncomingPaykitPaymentRequests() } } + .onReceive(PrivatePaykitService.initialLinkBurstStartedPublisher) { + initialPaykitSyncGeneration += 1 + } .onReceive(sheets.$activeSheetConfiguration) { configuration in guard configuration == nil else { return } Task { await presentNextIncomingPaykitPaymentRequest() } @@ -667,6 +679,11 @@ struct AppScene: View { contactsManager.contacts.map(\.publicKey), wallet: wallet ) + await PrivatePaykitService.shared.startInitialLinkBurst( + for: contactsManager.contacts.map(\.publicKey), + wallet: wallet, + reason: "wallet started" + ) await refreshIncomingPaykitPaymentRequests() } } else { @@ -704,10 +721,11 @@ struct AppScene: View { if PaykitFeatureFlags.isUIEnabled { await refreshPrivateOnlyPaykitReceiverMarker() let contactPublicKeys = contactsManager.contacts.map(\.publicKey) - await PrivatePaykitService.shared.refreshSavedContactEndpoints( + await PrivatePaykitService.shared.startInitialLinkBurst( for: contactPublicKeys, savedPublicKeys: contactPublicKeys, - wallet: wallet + wallet: wallet, + reason: "foreground" ) await refreshIncomingPaykitPaymentRequests() } @@ -752,6 +770,10 @@ struct AppScene: View { } catch { return } + await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( + wallet: wallet, + reason: "payment request polling" + ) let requestsChanged = await refreshIncomingPaykitPaymentRequests() if requestsChanged { refreshIntervalIndex = 0 @@ -761,6 +783,21 @@ struct AppScene: View { } } + private func pollIncomingPaykitPaymentRequestsDuringInitialSync() async { + guard scenePhase == .active else { return } + + await refreshIncomingPaykitPaymentRequests() + for delay in Self.initialPaykitSyncRetryDelays { + do { + try await Task.sleep(for: delay) + } catch { + return + } + guard scenePhase == .active else { return } + await refreshIncomingPaykitPaymentRequests() + } + } + private func presentNextIncomingPaykitPaymentRequest() async { guard sheets.activeSheetConfiguration == nil, app.contactPaymentContext == nil @@ -886,6 +923,15 @@ struct AppScene: View { // to display balances (MoneyText returns "0" if rates are nil) Task { await currency.refresh() + if PaykitFeatureFlags.isUIEnabled { + let contactPublicKeys = contactsManager.contacts.map(\.publicKey) + await PrivatePaykitService.shared.startInitialLinkBurst( + for: contactPublicKeys, + savedPublicKeys: contactPublicKeys, + wallet: wallet, + reason: "network restored" + ) + } await refreshIncomingPaykitPaymentRequests() } diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 54d037750..9c90c109f 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -75,8 +75,16 @@ enum Env { isE2E && e2eBackend == "local" } + private static var e2eLocalHost: String { + infoPlistValue("E2E_LOCAL_HOST") ?? "127.0.0.1" + } + private static var e2eHomegateUrl: String { - infoPlistValue("E2E_HOMEGATE_URL") ?? "http://127.0.0.1:6288" + infoPlistValue("E2E_HOMEGATE_URL") ?? "http://\(e2eLocalHost):6288" + } + + static var pubkyLocalTestnetHost: String? { + isLocalE2EBackend ? e2eLocalHost : nil } private static var e2eNetwork: LDKNode.Network { @@ -132,7 +140,9 @@ enum Env { /// Whether LN -> onchain swaps can reach a Boltz backend. Boltz only serves a public API on /// mainnet: its testnet deployment is deprecated and regtest resolves to a local backend that /// no build of ours can reach. Elsewhere the transfer to savings closes a channel instead. - static var isSwapSupported: Bool { network == .bitcoin } + static var isSwapSupported: Bool { + network == .bitcoin + } static let ldkLogLevel = LDKNode.LogLevel.trace @@ -180,7 +190,7 @@ enum Env { static var electrumServerUrl: String { if isE2E, e2eBackend == "local" { - return "tcp://127.0.0.1:60001" + return "tcp://\(e2eLocalHost):60001" } return electrumServerUrl(for: network) } diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 5ae0d535c..28416207a 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -21,6 +21,10 @@ E2E_BACKEND $(E2E_BACKEND) + E2E_HOMEGATE_URL + $(E2E_HOMEGATE_URL) + E2E_LOCAL_HOST + $(E2E_LOCAL_HOST) E2E_NETWORK $(E2E_NETWORK) TREZOR_BRIDGE diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 78a0ce976..266fce497 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -310,10 +310,11 @@ class ContactsManager: ObservableObject { do { let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey) _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name, receiverPaths: receiverPaths) - await PrivatePaykitService.shared.refreshSavedContactEndpoints( + await PrivatePaykitService.shared.startInitialLinkBurst( for: [prefixedKey], savedPublicKeys: contacts.map(\.publicKey), - wallet: wallet + wallet: wallet, + reason: "contact receiver refresh" ) } catch is CancellationError { return diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index aaca814e5..8b06f15c1 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -5,7 +5,21 @@ enum PubkyAuthClaim: String, Equatable { case watchOnlyAccountV1 = "watch-only-account-v1" static let queryParameter = "x-bitkit-claim" - static let watchOnlyAccountCapabilities = "/pub/paykit/v0/bitkit/server/:rw" + static let watchOnlyAccountCapabilities = "/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw" + private static let watchOnlyAccountCapabilitySet = Set(watchOnlyAccountCapabilities.split(separator: ",").map(String.init)) + + static func matchesWatchOnlyAccountCapabilities(_ capabilities: String) -> Bool { + guard let requestedCapabilitySet = capabilitySet(capabilities) else { return false } + return requestedCapabilitySet == watchOnlyAccountCapabilitySet + } + + private static func capabilitySet(_ capabilities: String) -> Set? { + let entries = capabilities + .split(separator: ",", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard !entries.contains(where: \.isEmpty) else { return nil } + return Set(entries) + } } enum PubkyAuthRequestError: Error, Equatable { @@ -53,7 +67,10 @@ struct PubkyAuthRequest { let details = try Paykit.parsePubkyAuthUrl(authUrl: url) let capabilities = details.capabilities ?? "" let permissions = parseCapabilities(capabilities) - let serviceNames = permissions.compactMap { extractServiceName($0.path) } + var seenServiceNames = Set() + let serviceNames = permissions + .compactMap { extractServiceName($0.path) } + .filter { seenServiceNames.insert($0).inserted } let bitkitClaim = try parseBitkitClaim(url: url, capabilities: capabilities) return PubkyAuthRequest( rawUrl: url, @@ -79,7 +96,7 @@ struct PubkyAuthRequest { throw PubkyAuthRequestError.duplicateBitkitClaim } guard let claimValue = claimValues.first else { - if capabilities == PubkyAuthClaim.watchOnlyAccountCapabilities { + if PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) { throw PubkyAuthRequestError.missingBitkitClaim } return nil @@ -87,7 +104,7 @@ struct PubkyAuthRequest { guard let claim = PubkyAuthClaim(rawValue: claimValue) else { throw PubkyAuthRequestError.unsupportedBitkitClaim(claimValue) } - guard capabilities == PubkyAuthClaim.watchOnlyAccountCapabilities else { + guard PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) else { throw PubkyAuthRequestError.invalidBitkitClaimCapabilities } diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 9235b6c4f..7020a5fb2 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -23,6 +23,10 @@ extension PrivatePaykitService { } func restoreBackup(_ backup: String?) async throws { + initialLinkBurstTask?.cancel() + initialLinkBurstTask = nil + initialLinkBurstPublicKeys.removeAll() + initialLinkBurstGeneration += 1 pendingMessageDrainRetryTask?.cancel() pendingMessageDrainRetryTask = nil pendingMessageDrainRetryKeys.removeAll() diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index f7360128e..0e0c2d151 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -57,6 +57,50 @@ extension PrivatePaykitService { ) } + func startInitialLinkBurst( + for publicKeys: [String], + savedPublicKeys: [String]? = nil, + wallet: WalletViewModel, + reason: String + ) { + if let savedPublicKeys { + _ = rememberSavedContacts(savedPublicKeys + publicKeys, replacing: false) + } + + let publicKeys = normalizedSavedContactKeys(publicKeys) + guard !publicKeys.isEmpty else { return } + + initialLinkBurstPublicKeys.formUnion(publicKeys) + initialLinkBurstGeneration += 1 + let generation = initialLinkBurstGeneration + initialLinkBurstTask?.cancel() + Self.initialLinkBurstStartedSubject.send() + + initialLinkBurstTask = Task { [reason, generation] in + for delay in [UInt64(0)] + Self.initialLinkBurstRetryDelays { + if delay > 0 { + try? await Task.sleep(nanoseconds: delay) + } + guard !Task.isCancelled, + generation == initialLinkBurstGeneration + else { return } + + let publicKeys = Array(initialLinkBurstPublicKeys) + _ = await refreshSavedContactEndpointsReturningError( + for: publicKeys, + wallet: wallet, + forceRefreshLightning: false, + requireImmediatePublication: false, + reason: "\(reason) initial link burst" + ) + } + + guard generation == initialLinkBurstGeneration else { return } + initialLinkBurstTask = nil + initialLinkBurstPublicKeys.removeAll() + } + } + @discardableResult func refreshSavedContactEndpointsReturningError( for publicKeys: [String], @@ -652,11 +696,39 @@ extension PrivatePaykitService { } private func receiverPathsForSavedContact(publicKey: String) async throws -> [String] { - guard let record = try await PaykitSdkService.shared.contactRecord(publicKey: publicKey) else { - return [PaykitReceiverPath.wallet] + let record = try await PaykitSdkService.shared.contactRecord(publicKey: publicKey) + let savedPaths = supportedReceiverPaths(record?.receiverPaths ?? []) + + do { + let discoveredPaths = try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey) + let mergedPaths = supportedReceiverPaths(savedPaths + discoveredPaths) + guard mergedPaths != savedPaths else { return savedPaths } + + let updatedRecord = try await PubkyService.saveContact( + publicKey: publicKey, + label: record?.label, + receiverPaths: mergedPaths + ) + Self.initialLinkBurstStartedSubject.send() + Logger.info( + "Discovered new Paykit receiver paths for \(PubkyPublicKeyFormat.redacted(publicKey))", + context: "PrivatePaykit" + ) + return supportedReceiverPaths(updatedRecord.receiverPaths) + } catch is CancellationError { + throw CancellationError() + } catch { + Logger.warn( + "Failed to refresh Paykit receiver paths for \(PubkyPublicKeyFormat.redacted(publicKey)); using saved paths: \(error)", + context: "PrivatePaykit" + ) + return savedPaths } + } - let paths = record.receiverPaths.filter { PaykitReceiverPath.supported.contains($0) } + func supportedReceiverPaths(_ receiverPaths: [String]) -> [String] { + let savedPaths = Set(receiverPaths) + let paths = PaykitReceiverPath.supported.filter { savedPaths.contains($0) } return paths.isEmpty ? [PaykitReceiverPath.wallet] : paths } diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 5d195fee1..4ddd46382 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -4,6 +4,10 @@ import Foundation extension PrivatePaykitService { func closeAndClear() async { + initialLinkBurstTask?.cancel() + initialLinkBurstTask = nil + initialLinkBurstPublicKeys.removeAll() + initialLinkBurstGeneration += 1 pendingMessageDrainRetryTask?.cancel() pendingMessageDrainRetryTask = nil pendingMessageDrainRetryKeys.removeAll() diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 3808d4187..871c7b1f3 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -43,11 +43,16 @@ actor PrivatePaykitService { static let shared = PrivatePaykitService() private static let walletBackupDataChangedSubject = PassthroughSubject() + static let initialLinkBurstStartedSubject = PassthroughSubject() nonisolated static var walletBackupDataChangedPublisher: AnyPublisher { walletBackupDataChangedSubject.eraseToAnyPublisher() } + nonisolated static var initialLinkBurstStartedPublisher: AnyPublisher { + initialLinkBurstStartedSubject.eraseToAnyPublisher() + } + static let invoiceRefreshBufferSeconds: TimeInterval = 30 * 60 static let maxReceivedInvoicePaymentHashesPerContact = 100 static let publishingEnabledKey = "sharesPrivatePaykitEndpoints" @@ -63,12 +68,16 @@ actor PrivatePaykitService { 45_000_000_000, 90_000_000_000, ] + static let initialLinkBurstRetryDelays = Array(repeating: UInt64(2_000_000_000), count: 14) var state: PrivatePaykitState var knownSavedContactKeys: Set = [] var pendingMessageDrainRetryTask: Task? var pendingMessageDrainRetryKeys: Set = [] var pendingMessageDrainRetryGeneration = 0 + var initialLinkBurstTask: Task? + var initialLinkBurstPublicKeys: Set = [] + var initialLinkBurstGeneration = 0 private let publicationLock = PrivatePaykitPublicationLock() init() { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 3ad14422c..97155d80f 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -297,6 +297,7 @@ actor PaykitSdkService { private let sessionProvider = PaykitSdkSessionProvider() private let paymentAdapter = PaykitSdkPaymentAdapter() private let operationLock = PaykitSdkOperationLock() + private let pubkyClientConfig = PaykitSdkService.makePubkyClientConfig(localTestnetHost: Env.pubkyLocalTestnetHost) private var sdk: PaykitSdk? private var activeAuthRequest: Paykit.PubkyAuthRequest? private var activeAuthRequestID: UUID? @@ -789,11 +790,12 @@ actor PaykitSdkService { return sdk } - let created = try PaykitSdk.withPaymentAdapter( + let created = try PaykitSdk.withPaymentAdapterAndPubkyClientConfig( stateStore: stateStore, sessionProvider: sessionProvider, paymentAdapter: paymentAdapter, - config: Self.config() + config: Self.config(), + pubkyClient: pubkyClientConfig ) sdk = created return created @@ -925,7 +927,13 @@ actor PaykitSdkService { } private func bootstrap() throws -> PubkySessionBootstrap { - try PubkySessionBootstrap() + try PubkySessionBootstrap.withPubkyClientConfig(pubkyClient: pubkyClientConfig) + } + + nonisolated static func makePubkyClientConfig(localTestnetHost: String?) -> PubkyClientConfig { + var config = Paykit.defaultPubkyClientConfig() + config.localTestnetHost = localTestnetHost + return config } private nonisolated static func config() throws -> PaykitSdkConfig { diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift new file mode 100644 index 000000000..67dfb1a10 --- /dev/null +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -0,0 +1,17 @@ +@testable import Bitkit +import Paykit +import XCTest + +final class PaykitSdkClientConfigTests: XCTestCase { + func testProductionUsesDefaultPubkyClient() { + let config = PaykitSdkService.makePubkyClientConfig(localTestnetHost: nil) + + XCTAssertNil(config.localTestnetHost) + } + + func testLocalE2EUsesLocalPubkyTestnet() { + let config = PaykitSdkService.makePubkyClientConfig(localTestnetHost: "192.0.2.1") + + XCTAssertEqual(config.localTestnetHost, "192.0.2.1") + } +} diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index bf2ae2025..fb8407358 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -2,6 +2,21 @@ import XCTest final class PrivatePaykitServiceTests: XCTestCase { + func testSupportedReceiverPathsPreserveSupportedOrderWhenMergingDiscoveredServerPath() async { + let service = PrivatePaykitService() + + let initiallySavedPaths = await service.supportedReceiverPaths([PaykitReceiverPath.wallet]) + let rediscoveredPaths = await service.supportedReceiverPaths(initiallySavedPaths + [PaykitReceiverPath.server]) + + XCTAssertEqual(initiallySavedPaths, [PaykitReceiverPath.wallet]) + XCTAssertEqual(rediscoveredPaths, [PaykitReceiverPath.wallet, PaykitReceiverPath.server]) + } + + func testInitialLinkBurstUsesBoundedTwoSecondRetryCadence() { + XCTAssertEqual(PrivatePaykitService.initialLinkBurstRetryDelays.count, 14) + XCTAssertTrue(PrivatePaykitService.initialLinkBurstRetryDelays.allSatisfy { $0 == 2_000_000_000 }) + } + func testReceiverNoiseDerivationMatchesCrossPlatformVector() { let seed = ( "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index 1f67a08fe..cc1d17dc9 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -7,6 +7,12 @@ private let approvalTestXpub = "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" +private func approvalTestAuthUrl(secret: String = "e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s") -> String { + "pubkyauth://signin?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https://httprelay.pubky.app/inbox/&secret=\(secret)" + + "&x-bitkit-claim=watch-only-account-v1" +} + final class PubkyAuthApprovalSheetTests: XCTestCase { func testAuthDisplayPublicKeyOmitsPubkyPrefix() { XCTAssertEqual(pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg"), "3rsd...w5xg") @@ -16,7 +22,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { @MainActor func testWatchOnlyRequestStartsWithSeparateConsentBeforeAuthorization() throws { - let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let authUrl = approvalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) var state = PubkyAuthApprovalSheet.initialState(for: request) @@ -97,7 +103,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let authUrl = approvalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) let node = ApprovalFakeWatchOnlyAccountNode() let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) @@ -134,7 +140,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let authUrl = approvalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) let node = ApprovalFakeWatchOnlyAccountNode() let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) @@ -168,8 +174,8 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let firstAuthUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" - let secondAuthUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=f3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let firstAuthUrl = approvalTestAuthUrl() + let secondAuthUrl = approvalTestAuthUrl(secret: "f3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s") let firstRequest = try PubkyAuthRequest.parse(url: firstAuthUrl) let secondRequest = try PubkyAuthRequest.parse(url: secondAuthUrl) let node = ApprovalFakeWatchOnlyAccountNode() @@ -229,7 +235,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let authUrl = approvalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) let node = ApprovalFakeWatchOnlyAccountNode() let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) @@ -258,7 +264,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let authUrl = approvalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) let node = ApprovalFakeWatchOnlyAccountNode() let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) @@ -298,7 +304,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let authUrl = approvalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) let node = ApprovalFakeWatchOnlyAccountNode() let initialManager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) @@ -349,7 +355,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let authUrl = approvalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) let node = ApprovalFakeWatchOnlyAccountNode() node.failNextTrackingPreparation = true @@ -379,7 +385,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let authUrl = approvalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) let node = ApprovalFakeWatchOnlyAccountNode() node.checkCancellationWhenDisabling = true diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 0539982f5..b1b4af240 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -24,6 +24,24 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertEqual(request.permissions[0].path, "/pub/bitkit.to/") } + func testParseUrlDeduplicatesServiceNameAcrossPublicAndPrivateCapabilities() throws { + let capabilities = "/pub/locks.app/:rw,/priv/locks.app/:rw" + + let request = try PubkyAuthRequest.parse(url: authUrl(capabilities: capabilities)) + + XCTAssertEqual(request.permissions.map(\.path), ["/pub/locks.app/", "/priv/locks.app/"]) + XCTAssertEqual(request.serviceNames, ["locks.app"]) + } + + func testParseUrlDeduplicatesServiceNamesAcrossMultiplePathsInFirstSeenOrder() throws { + let capabilities = "/pub/locks.app/posts/:r,/pub/example.app/:r,/priv/locks.app/settings/:w,/priv/example.app/cache/:r" + + let request = try PubkyAuthRequest.parse(url: authUrl(capabilities: capabilities)) + + XCTAssertEqual(request.permissions.count, 4) + XCTAssertEqual(request.serviceNames, ["locks.app", "example.app"]) + } + func testParseUrlRecognizesWatchOnlyAccountClaim() throws { let capabilities = PubkyAuthClaim.watchOnlyAccountCapabilities let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) @@ -33,6 +51,27 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) } + func testParseUrlRecognizesWatchOnlyAccountClaimWithReorderedCapabilities() throws { + let capabilities = PubkyAuthClaim.watchOnlyAccountCapabilities + .split(separator: ",") + .reversed() + .joined(separator: ",") + let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) + + let request = try PubkyAuthRequest.parse(url: url) + + XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) + } + + func testParseUrlRecognizesWatchOnlyAccountClaimWithCapabilityWhitespace() throws { + let capabilities = PubkyAuthClaim.watchOnlyAccountCapabilities.replacingOccurrences(of: ",", with: " , ") + let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) + + let request = try PubkyAuthRequest.parse(url: url) + + XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) + } + func testParseUrlWithoutBitkitClaimPreservesNormalAuth() throws { let request = try PubkyAuthRequest.parse(url: authUrl(capabilities: "/pub/bitkit.to/:rw")) @@ -74,6 +113,21 @@ final class PubkyAuthRequestTests: XCTestCase { } } + func testParseUrlRejectsWatchOnlyClaimWithoutPrivateCapability() { + let capabilities = "/pub/paykit/v0/bitkit/server/:rw" + let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .invalidBitkitClaimCapabilities) + } + } + + func testWatchOnlyCapabilityMatcherRejectsEmptyCapability() { + let capabilities = "\(PubkyAuthClaim.watchOnlyAccountCapabilities)," + + XCTAssertFalse(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) + } + // MARK: - parseCapabilities func testParseCapabilitiesSingleEntry() { diff --git a/Docs/watch-only-account-claim-v1.md b/Docs/watch-only-account-claim-v1.md index bc043e916..71f15d3e0 100644 --- a/Docs/watch-only-account-claim-v1.md +++ b/Docs/watch-only-account-claim-v1.md @@ -5,7 +5,7 @@ This document records the client contract implemented by Bitkit iOS and Android ## Request - The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. -- The exact capability is `/pub/paykit/v0/bitkit/server/:rw`. +- The exact capabilities are `/pub/paykit/v0/bitkit/server/:rw` and `/pub/paykit/v0/private/bitkit/server/:rw`. - Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. - Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. - Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. diff --git a/README.md b/README.md index a9bb499d0..b8f9d99d4 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ E2E_BACKEND=network E2E_NETWORK=bitcoin \ build ``` +Local E2E builds use `127.0.0.1` for Electrum, Homegate, and the Pubky/Paykit testnet when running in the simulator. Set `E2E_LOCAL_HOST` +to the development machine's LAN address when running on a physical device. `E2E_HOMEGATE_URL` remains available when Homegate needs a +different host or port. + ## Localization ### Pulling Translations diff --git a/changelog.d/next/653.fixed.md b/changelog.d/next/653.fixed.md new file mode 100644 index 000000000..70ddd2261 --- /dev/null +++ b/changelog.d/next/653.fixed.md @@ -0,0 +1 @@ +Fixed Paykit Server authorization, removed duplicate service names from permission summaries, and made private payment request connections establish promptly after contact discovery.