From 09d0cb68576e7d1bd7379cb50fa21c5a9b12de1e Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 4 Aug 2026 18:22:21 +0200 Subject: [PATCH 1/9] fix: require private Paykit auth scope --- Bitkit/Info.plist | 2 ++ Bitkit/Models/PubkyAuthRequest.swift | 12 +++++++++--- BitkitTests/PubkyAuthRequestTests.swift | 21 +++++++++++++++++++++ changelog.d/next/pending.fixed.md | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 changelog.d/next/pending.fixed.md diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 5ae0d535c..e7804ec2b 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -21,6 +21,8 @@ E2E_BACKEND $(E2E_BACKEND) + E2E_HOMEGATE_URL + $(E2E_HOMEGATE_URL) E2E_NETWORK $(E2E_NETWORK) TREZOR_BRIDGE diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index aaca814e5..23efee576 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -5,7 +5,13 @@ 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 { + let requestedCapabilitySet = Set(capabilities.split(separator: ",").map(String.init)) + return requestedCapabilitySet == watchOnlyAccountCapabilitySet + } } enum PubkyAuthRequestError: Error, Equatable { @@ -79,7 +85,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 +93,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/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 0539982f5..a45b423ff 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -33,6 +33,18 @@ 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 testParseUrlWithoutBitkitClaimPreservesNormalAuth() throws { let request = try PubkyAuthRequest.parse(url: authUrl(capabilities: "/pub/bitkit.to/:rw")) @@ -74,6 +86,15 @@ 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) + } + } + // MARK: - parseCapabilities func testParseCapabilitiesSingleEntry() { diff --git a/changelog.d/next/pending.fixed.md b/changelog.d/next/pending.fixed.md new file mode 100644 index 000000000..5e47caa71 --- /dev/null +++ b/changelog.d/next/pending.fixed.md @@ -0,0 +1 @@ +Fixed Paykit Server authorization to require both public and private payment capabilities. From 88bb13d7a1f60908608f4039d474428de8c921ce Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 4 Aug 2026 19:10:58 +0200 Subject: [PATCH 2/9] chore: name changelog fragment --- changelog.d/next/{pending.fixed.md => 653.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{pending.fixed.md => 653.fixed.md} (100%) diff --git a/changelog.d/next/pending.fixed.md b/changelog.d/next/653.fixed.md similarity index 100% rename from changelog.d/next/pending.fixed.md rename to changelog.d/next/653.fixed.md From fcdcf86c6d59ed99b60577c65ae75d1de93413b3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 4 Aug 2026 20:46:14 +0200 Subject: [PATCH 3/9] feat: use Paykit local testnet in E2E builds --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Bitkit/Constants/Env.swift | 2 +- Bitkit/Services/PubkyService.swift | 15 ++++++++++++--- BitkitTests/PaykitSdkClientConfigTests.swift | 19 +++++++++++++++++++ 5 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 BitkitTests/PaykitSdkClientConfigTests.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 35ab2455b..f5199ef97 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-rc41"; }; }; 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..fb9a0ada1 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" : "f6d43c33ef479051a9e4b54c0d1c48e3b170cbaa", + "version" : "0.1.0-rc41" } }, { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 54d037750..fe723acc0 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -71,7 +71,7 @@ enum Env { (infoPlistValue("E2E_BACKEND") ?? "local").lowercased() } - private static var isLocalE2EBackend: Bool { + static var isLocalE2EBackend: Bool { isE2E && e2eBackend == "local" } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 3ad14422c..2957c325f 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -789,11 +789,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: Self.pubkyClientConfig() ) sdk = created return created @@ -925,7 +926,15 @@ actor PaykitSdkService { } private func bootstrap() throws -> PubkySessionBootstrap { - try PubkySessionBootstrap() + try PubkySessionBootstrap.withPubkyClientConfig(pubkyClient: Self.pubkyClientConfig()) + } + + nonisolated static func pubkyClientConfig(isLocalE2EBackend: Bool = Env.isLocalE2EBackend) -> PubkyClientConfig { + var config = Paykit.defaultPubkyClientConfig() + if isLocalE2EBackend { + config.environment = .localTestnet + } + 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..9d1453b29 --- /dev/null +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -0,0 +1,19 @@ +@testable import Bitkit +import Paykit +import XCTest + +final class PaykitSdkClientConfigTests: XCTestCase { + func testProductionUsesDefaultPubkyClient() { + let config = PaykitSdkService.pubkyClientConfig(isLocalE2EBackend: false) + + XCTAssertEqual(config.environment, .production) + XCTAssertNil(config.testnetHost) + } + + func testLocalE2EUsesLocalPubkyTestnet() { + let config = PaykitSdkService.pubkyClientConfig(isLocalE2EBackend: true) + + XCTAssertEqual(config.environment, .localTestnet) + XCTAssertNil(config.testnetHost) + } +} From 8b7d18b55449c74e9897d6d4349ab4db6d64602d Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 5 Aug 2026 10:29:17 -0500 Subject: [PATCH 4/9] chore: update Paykit to rc42 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Bitkit/Services/PubkyService.swift | 2 +- BitkitTests/PaykitSdkClientConfigTests.swift | 6 ++---- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f5199ef97..0a018d4c0 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-rc41"; + version = "0.1.0-rc42"; }; }; 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 fb9a0ada1..f9a350a76 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" : "f6d43c33ef479051a9e4b54c0d1c48e3b170cbaa", - "version" : "0.1.0-rc41" + "revision" : "192fc700897c3792ae4af221591572f3472089e1", + "version" : "0.1.0-rc42" } }, { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 2957c325f..5ce423b8b 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -932,7 +932,7 @@ actor PaykitSdkService { nonisolated static func pubkyClientConfig(isLocalE2EBackend: Bool = Env.isLocalE2EBackend) -> PubkyClientConfig { var config = Paykit.defaultPubkyClientConfig() if isLocalE2EBackend { - config.environment = .localTestnet + config.localTestnetHost = "localhost" } return config } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 9d1453b29..2384b4108 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -6,14 +6,12 @@ final class PaykitSdkClientConfigTests: XCTestCase { func testProductionUsesDefaultPubkyClient() { let config = PaykitSdkService.pubkyClientConfig(isLocalE2EBackend: false) - XCTAssertEqual(config.environment, .production) - XCTAssertNil(config.testnetHost) + XCTAssertNil(config.localTestnetHost) } func testLocalE2EUsesLocalPubkyTestnet() { let config = PaykitSdkService.pubkyClientConfig(isLocalE2EBackend: true) - XCTAssertEqual(config.environment, .localTestnet) - XCTAssertNil(config.testnetHost) + XCTAssertEqual(config.localTestnetHost, "localhost") } } From ffd6fcbeae038214effef64597af5f00aff60a1a Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 5 Aug 2026 17:17:21 -0500 Subject: [PATCH 5/9] fix: address Pubky auth review --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +-- Bitkit/Models/PubkyAuthRequest.swift | 10 ++++++- BitkitTests/PubkyAuthApprovalSheetTests.swift | 26 ++++++++++++------- BitkitTests/PubkyAuthRequestTests.swift | 15 +++++++++++ Docs/watch-only-account-claim-v1.md | 2 +- 6 files changed, 44 insertions(+), 15 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 0a018d4c0..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-rc42"; + 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 f9a350a76..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" : "192fc700897c3792ae4af221591572f3472089e1", - "version" : "0.1.0-rc42" + "revision" : "6b241878a9bba5cecea919c0298c3f90624be6ff", + "version" : "0.1.0-rc43" } }, { diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 23efee576..6d04467f2 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -9,9 +9,17 @@ enum PubkyAuthClaim: String, Equatable { private static let watchOnlyAccountCapabilitySet = Set(watchOnlyAccountCapabilities.split(separator: ",").map(String.init)) static func matchesWatchOnlyAccountCapabilities(_ capabilities: String) -> Bool { - let requestedCapabilitySet = Set(capabilities.split(separator: ",").map(String.init)) + 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 { 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 a45b423ff..39c401f30 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -45,6 +45,15 @@ final class PubkyAuthRequestTests: XCTestCase { 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")) @@ -95,6 +104,12 @@ final class PubkyAuthRequestTests: XCTestCase { } } + 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. From e6173fd496741c30d11c240ff69c4a267560eb90 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 6 Aug 2026 10:30:47 -0500 Subject: [PATCH 6/9] fix: establish private Paykit links promptly --- Bitkit/AppScene.swift | 50 +++++++++++- Bitkit/Managers/ContactsManager.swift | 5 +- .../PrivatePaykitService+Backup.swift | 4 + .../PrivatePaykitService+Contacts.swift | 78 ++++++++++++++++++- .../Services/PrivatePaykitService+State.swift | 4 + Bitkit/Services/PrivatePaykitService.swift | 9 +++ BitkitTests/PrivatePaykitServiceTests.swift | 11 +++ changelog.d/next/653.fixed.md | 2 +- 8 files changed, 155 insertions(+), 8 deletions(-) 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/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/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/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index bf2ae2025..ea47019a6 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -2,6 +2,17 @@ import XCTest final class PrivatePaykitServiceTests: XCTestCase { + func testSupportedReceiverPathsIncludeServerPublishedAfterContactWasSaved() 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]) + XCTAssertEqual(PrivatePaykitService.initialLinkBurstRetryDelays.count, 14) + } + func testReceiverNoiseDerivationMatchesCrossPlatformVector() { let seed = ( "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + diff --git a/changelog.d/next/653.fixed.md b/changelog.d/next/653.fixed.md index 5e47caa71..fad7e7a9e 100644 --- a/changelog.d/next/653.fixed.md +++ b/changelog.d/next/653.fixed.md @@ -1 +1 @@ -Fixed Paykit Server authorization to require both public and private payment capabilities. +Fixed Paykit Server authorization and made private payment request connections establish promptly after contact discovery. From 59f776302df0f0c9b0947696414a49ee495e06a7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 6 Aug 2026 10:36:34 -0500 Subject: [PATCH 7/9] fix: centralize local E2E host --- Bitkit/Constants/Env.swift | 18 ++++++++++++++---- Bitkit/Info.plist | 2 ++ Bitkit/Services/PubkyService.swift | 11 +++++------ BitkitTests/PaykitSdkClientConfigTests.swift | 6 +++--- README.md | 4 ++++ 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index fe723acc0..9c90c109f 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -71,12 +71,20 @@ enum Env { (infoPlistValue("E2E_BACKEND") ?? "local").lowercased() } - static var isLocalE2EBackend: Bool { + private static var isLocalE2EBackend: Bool { 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 e7804ec2b..28416207a 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -23,6 +23,8 @@ $(E2E_BACKEND) E2E_HOMEGATE_URL $(E2E_HOMEGATE_URL) + E2E_LOCAL_HOST + $(E2E_LOCAL_HOST) E2E_NETWORK $(E2E_NETWORK) TREZOR_BRIDGE diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 5ce423b8b..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? @@ -794,7 +795,7 @@ actor PaykitSdkService { sessionProvider: sessionProvider, paymentAdapter: paymentAdapter, config: Self.config(), - pubkyClient: Self.pubkyClientConfig() + pubkyClient: pubkyClientConfig ) sdk = created return created @@ -926,14 +927,12 @@ actor PaykitSdkService { } private func bootstrap() throws -> PubkySessionBootstrap { - try PubkySessionBootstrap.withPubkyClientConfig(pubkyClient: Self.pubkyClientConfig()) + try PubkySessionBootstrap.withPubkyClientConfig(pubkyClient: pubkyClientConfig) } - nonisolated static func pubkyClientConfig(isLocalE2EBackend: Bool = Env.isLocalE2EBackend) -> PubkyClientConfig { + nonisolated static func makePubkyClientConfig(localTestnetHost: String?) -> PubkyClientConfig { var config = Paykit.defaultPubkyClientConfig() - if isLocalE2EBackend { - config.localTestnetHost = "localhost" - } + config.localTestnetHost = localTestnetHost return config } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 2384b4108..67dfb1a10 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -4,14 +4,14 @@ import XCTest final class PaykitSdkClientConfigTests: XCTestCase { func testProductionUsesDefaultPubkyClient() { - let config = PaykitSdkService.pubkyClientConfig(isLocalE2EBackend: false) + let config = PaykitSdkService.makePubkyClientConfig(localTestnetHost: nil) XCTAssertNil(config.localTestnetHost) } func testLocalE2EUsesLocalPubkyTestnet() { - let config = PaykitSdkService.pubkyClientConfig(isLocalE2EBackend: true) + let config = PaykitSdkService.makePubkyClientConfig(localTestnetHost: "192.0.2.1") - XCTAssertEqual(config.localTestnetHost, "localhost") + XCTAssertEqual(config.localTestnetHost, "192.0.2.1") } } 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 From cf5b17f156cbc0d2ede88575f45ba0756864f5cf Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 6 Aug 2026 14:09:18 -0500 Subject: [PATCH 8/9] test: clarify Paykit burst coverage --- BitkitTests/PrivatePaykitServiceTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index ea47019a6..fb8407358 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -2,7 +2,7 @@ import XCTest final class PrivatePaykitServiceTests: XCTestCase { - func testSupportedReceiverPathsIncludeServerPublishedAfterContactWasSaved() async { + func testSupportedReceiverPathsPreserveSupportedOrderWhenMergingDiscoveredServerPath() async { let service = PrivatePaykitService() let initiallySavedPaths = await service.supportedReceiverPaths([PaykitReceiverPath.wallet]) @@ -10,7 +10,11 @@ final class PrivatePaykitServiceTests: XCTestCase { 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() { From b14c0e345de1b4d8ca9934e75a3fdc8191ff0c4d Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 6 Aug 2026 18:03:27 -0500 Subject: [PATCH 9/9] fix: deduplicate Pubky auth service names --- Bitkit/Models/PubkyAuthRequest.swift | 5 ++++- BitkitTests/PubkyAuthRequestTests.swift | 18 ++++++++++++++++++ changelog.d/next/653.fixed.md | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 6d04467f2..8b06f15c1 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -67,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, diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 39c401f30..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]) diff --git a/changelog.d/next/653.fixed.md b/changelog.d/next/653.fixed.md index fad7e7a9e..70ddd2261 100644 --- a/changelog.d/next/653.fixed.md +++ b/changelog.d/next/653.fixed.md @@ -1 +1 @@ -Fixed Paykit Server authorization and made private payment request connections establish promptly after contact discovery. +Fixed Paykit Server authorization, removed duplicate service names from permission summaries, and made private payment request connections establish promptly after contact discovery.