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.