Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,7 @@
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = exactVersion;
version = "0.1.0-rc39";
version = "0.1.0-rc43";
Comment thread
ben-kaufman marked this conversation as resolved.
};
};
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 48 additions & 2 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
}

Expand Down
16 changes: 13 additions & 3 deletions Bitkit/Constants/Env.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
</array>
<key>E2E_BACKEND</key>
<string>$(E2E_BACKEND)</string>
<key>E2E_HOMEGATE_URL</key>
<string>$(E2E_HOMEGATE_URL)</string>
Comment thread
ben-kaufman marked this conversation as resolved.
<key>E2E_LOCAL_HOST</key>
<string>$(E2E_LOCAL_HOST)</string>
<key>E2E_NETWORK</key>
<string>$(E2E_NETWORK)</string>
Comment thread
ben-kaufman marked this conversation as resolved.
<key>TREZOR_BRIDGE</key>
Expand Down
5 changes: 3 additions & 2 deletions Bitkit/Managers/ContactsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions Bitkit/Models/PubkyAuthRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
ben-kaufman marked this conversation as resolved.
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<String>? {
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 {
Expand Down Expand Up @@ -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<String>()
let serviceNames = permissions
.compactMap { extractServiceName($0.path) }
.filter { seenServiceNames.insert($0).inserted }
let bitkitClaim = try parseBitkitClaim(url: url, capabilities: capabilities)
return PubkyAuthRequest(
rawUrl: url,
Expand All @@ -79,15 +96,15 @@ struct PubkyAuthRequest {
throw PubkyAuthRequestError.duplicateBitkitClaim
}
guard let claimValue = claimValues.first else {
if capabilities == PubkyAuthClaim.watchOnlyAccountCapabilities {
if PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) {
throw PubkyAuthRequestError.missingBitkitClaim
}
return nil
}
guard let claim = PubkyAuthClaim(rawValue: claimValue) else {
throw PubkyAuthRequestError.unsupportedBitkitClaim(claimValue)
}
guard capabilities == PubkyAuthClaim.watchOnlyAccountCapabilities else {
guard PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) else {
throw PubkyAuthRequestError.invalidBitkitClaimCapabilities
}

Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Services/PrivatePaykitService+Backup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
78 changes: 75 additions & 3 deletions Bitkit/Services/PrivatePaykitService+Contacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Services/PrivatePaykitService+State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading