Skip to content

Commit f1a122e

Browse files
committed
fix: complete public Paykit contact payments
1 parent 834fe17 commit f1a122e

13 files changed

Lines changed: 317 additions & 54 deletions

File tree

Bitkit.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -911,8 +911,8 @@
911911
isa = XCRemoteSwiftPackageReference;
912912
repositoryURL = "https://github.com/pubky/paykit-rs";
913913
requirement = {
914-
kind = revision;
915-
revision = cd1253291b1582759d569372d5942b8871527ea1;
914+
kind = exactVersion;
915+
version = 0.1.0-rc3;
916916
};
917917
};
918918
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {

Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Bitkit/Constants/Env.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ enum Env {
272272
case .bitcoin:
273273
return "/pub/bitkit.to/:rw,/pub/pubky.app/:r,/pub/paykit/v0/:rw"
274274
default:
275-
return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/staging.paykit/v0/:rw"
275+
return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/paykit/v0/:rw"
276276
}
277277
}
278278

Bitkit/Resources/Localization/en.lproj/Localizable.strings

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,7 @@
10401040
"slashtags__error_deleting_profile" = "Unable To Delete Profile";
10411041
"slashtags__error_pay_title" = "Unable To Pay Contact";
10421042
"slashtags__error_pay_empty_msg" = "The contact you're trying to send to hasn't enabled payments.";
1043+
"slashtags__error_pay_not_opened_msg" = "No compatible payment endpoint is available.";
10431044
"slashtags__auth_depricated_title" = "Deprecated";
10441045
"slashtags__auth_depricated_msg" = "Slashauth is deprecated. Please use Bitkit Beta.";
10451046
"profile__nav_title" = "Profile";

Bitkit/Services/PublicPaykitService.swift

Lines changed: 106 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,23 @@ enum PublicPaykitPaymentLaunchResult {
2323
case opened
2424
case noEndpoint
2525
case notOpened
26+
27+
var contactPaymentFailureMessageKey: String? {
28+
switch self {
29+
case .opened:
30+
nil
31+
case .noEndpoint:
32+
"slashtags__error_pay_empty_msg"
33+
case .notOpened:
34+
"slashtags__error_pay_not_opened_msg"
35+
}
36+
}
2637
}
2738

2839
enum PublicPaykitService {
2940
enum MethodId: String, Hashable {
3041
case bitcoinLightningBolt11 = "btc-lightning-bolt11"
31-
case bitcoinLightningLnurl = "btc-lightning-lnurl-pay"
42+
case bitcoinLightningLnurl = "btc-lightning-lnurl"
3243
case bitcoinOnchainP2tr = "btc-bitcoin-p2tr"
3344
case bitcoinOnchainP2wpkh = "btc-bitcoin-p2wpkh"
3445
case bitcoinOnchainP2sh = "btc-bitcoin-p2sh"
@@ -50,6 +61,13 @@ enum PublicPaykitService {
5061
.bitcoinOnchainP2sh,
5162
.bitcoinOnchainP2pkh,
5263
]
64+
65+
static let onchainPreferenceOrder: [MethodId] = [
66+
.bitcoinOnchainP2tr,
67+
.bitcoinOnchainP2wpkh,
68+
.bitcoinOnchainP2sh,
69+
.bitcoinOnchainP2pkh,
70+
]
5371
}
5472

5573
struct Endpoint: Equatable, Hashable {
@@ -114,7 +132,7 @@ enum PublicPaykitService {
114132
@MainActor
115133
static func syncPublishedEndpoints(wallet: WalletViewModel, publish: Bool) async throws {
116134
guard publish else {
117-
await removePublishedEndpoints()
135+
try await removePublishedEndpoints()
118136
return
119137
}
120138

@@ -128,12 +146,36 @@ enum PublicPaykitService {
128146
try await applyPublishedEndpoints(desiredEndpoints)
129147
}
130148

131-
static func removePublishedEndpoints() async {
132-
for methodId in MethodId.publishableMethodIds {
133-
try? await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue)
149+
static func removePublishedEndpoints() async throws {
150+
let existingMethodIds = try await currentPublishedMethodIds()
151+
152+
for methodId in MethodId.publishableMethodIds where existingMethodIds.contains(methodId) {
153+
try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue)
134154
}
135155
}
136156

157+
static func hasPayablePublicEndpoint(publicKey: String) async throws -> Bool {
158+
let endpoints = try await payablePublicEndpoints(publicKey: publicKey)
159+
return !endpoints.isEmpty
160+
}
161+
162+
static func payablePublicEndpoints(publicKey: String) async throws -> [Endpoint] {
163+
let endpoints = try await fetchPublicEndpoints(publicKey: publicKey)
164+
return await payableEndpoints(from: endpoints)
165+
}
166+
167+
static func payableEndpoints(from endpoints: [Endpoint]) async -> [Endpoint] {
168+
var payableEndpoints: [Endpoint] = []
169+
170+
for endpoint in endpoints {
171+
if await isPayableEndpoint(endpoint) {
172+
payableEndpoints.append(endpoint)
173+
}
174+
}
175+
176+
return payableEndpoints
177+
}
178+
137179
@MainActor
138180
static func beginPayment(
139181
to publicKey: String,
@@ -143,28 +185,65 @@ enum PublicPaykitService {
143185
sheets: SheetViewModel
144186
) async throws -> PublicPaykitPaymentLaunchResult {
145187
let endpoints = try await fetchPublicEndpoints(publicKey: publicKey)
188+
let payableEndpoints = await payableEndpoints(from: endpoints)
146189

147-
guard let preferredEndpoint = await preferredPayableEndpoint(from: endpoints) else {
190+
guard !payableEndpoints.isEmpty else {
148191
return endpoints.isEmpty ? .noEndpoint : .notOpened
149192
}
150193

151-
try await app.handleScannedData(preferredEndpoint.paymentRequest)
194+
try await app.handleScannedData(paymentRequest(from: payableEndpoints))
152195

153-
guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else {
196+
guard let route = contactPaymentRoute(app: app, currency: currency, settings: settings) else {
154197
return .notOpened
155198
}
156199

157200
app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey)
158-
PaymentNavigationHelper.openPaymentSheet(
159-
app: app,
160-
currency: currency,
161-
settings: settings,
162-
sheetViewModel: sheets
163-
)
201+
sheets.showSheet(.send, data: SendConfig(view: route))
164202

165203
return .opened
166204
}
167205

206+
static func paymentRequest(from endpoints: [Endpoint]) -> String {
207+
guard let onchainEndpoint = MethodId.onchainPreferenceOrder.compactMap({ methodId in endpoints.first { $0.methodId == methodId } }).first,
208+
let bolt11Endpoint = endpoints.first(where: { $0.methodId == .bitcoinLightningBolt11 })
209+
else {
210+
return endpoints.first?.paymentRequest ?? ""
211+
}
212+
213+
return "bitcoin:\(onchainEndpoint.paymentRequest)?lightning=\(bolt11Endpoint.paymentRequest)"
214+
}
215+
216+
@MainActor
217+
private static func contactPaymentRoute(
218+
app: AppViewModel,
219+
currency: CurrencyViewModel,
220+
settings: SettingsViewModel
221+
) -> SendRoute? {
222+
guard let route = PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) else {
223+
return nil
224+
}
225+
226+
switch route {
227+
case .quickpay:
228+
if app.lnurlPayData != nil {
229+
return .lnurlPayAmount
230+
}
231+
232+
if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil {
233+
return .amount
234+
}
235+
236+
return route
237+
case .confirm:
238+
if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil {
239+
return .amount
240+
}
241+
return route
242+
default:
243+
return route
244+
}
245+
}
246+
168247
static func onchainMethodId(for address: String) -> MethodId {
169248
let normalizedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
170249

@@ -220,11 +299,22 @@ enum PublicPaykitService {
220299
)
221300
}
222301

223-
for methodId in MethodId.publishableMethodIds where !desiredMethodIds.contains(methodId) {
224-
try? await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue)
302+
let existingMethodIds = try await currentPublishedMethodIds()
303+
304+
for methodId in MethodId.publishableMethodIds where existingMethodIds.contains(methodId) && !desiredMethodIds.contains(methodId) {
305+
try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue)
225306
}
226307
}
227308

309+
private static func currentPublishedMethodIds() async throws -> Set<MethodId> {
310+
guard let publicKey = await PubkyService.currentPublicKey() else {
311+
throw PubkyServiceError.sessionNotActive
312+
}
313+
314+
let paymentEntries = try await PubkyService.getPaymentList(publicKey: publicKey)
315+
return Set(paymentEntries.compactMap { MethodId(rawValue: $0.methodId) })
316+
}
317+
228318
@MainActor
229319
private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool) async throws -> [Endpoint] {
230320
if refreshIfNeeded {
@@ -272,16 +362,6 @@ enum PublicPaykitService {
272362
return endpoints
273363
}
274364

275-
private static func preferredPayableEndpoint(from endpoints: [Endpoint]) async -> Endpoint? {
276-
for endpoint in endpoints {
277-
if await isPayableEndpoint(endpoint) {
278-
return endpoint
279-
}
280-
}
281-
282-
return nil
283-
}
284-
285365
private static func isPayableEndpoint(_ endpoint: Endpoint) async -> Bool {
286366
switch endpoint.methodId {
287367
case .bitcoinLightningBolt11:

Bitkit/ViewModels/AppViewModel.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class AppViewModel: ObservableObject {
6767
/// Payment hashes for which we navigated to the pending screen.
6868
/// When payment succeeds/fails, we show toast and publish resolution so SendPendingScreen can navigate.
6969
private var pendingPaymentHashes: Set<String> = []
70+
private var pendingContactPaymentContexts: [String: ContactPaymentContext] = [:]
7071

7172
/// When a payment that was shown on the pending screen succeeds or fails, this is set so SendPendingScreen can navigate.
7273
/// Consumed by SendPendingScreen via consumeSendSheetPendingResolution.
@@ -293,8 +294,20 @@ extension AppViewModel {
293294
// MARK: Pending payment tracking
294295

295296
extension AppViewModel {
296-
func addPendingPaymentHash(_ hash: String) {
297+
func addPendingPaymentHash(_ hash: String, contactPublicKey: String? = nil) {
297298
pendingPaymentHashes.insert(hash)
299+
300+
if let contactPublicKey {
301+
pendingContactPaymentContexts[hash] = ContactPaymentContext(publicKey: contactPublicKey)
302+
}
303+
}
304+
305+
func contactPaymentContext(forPendingPaymentHash hash: String) -> ContactPaymentContext? {
306+
pendingContactPaymentContexts[hash]
307+
}
308+
309+
func consumeContactPaymentContext(forPendingPaymentHash hash: String) {
310+
pendingContactPaymentContexts.removeValue(forKey: hash)
298311
}
299312

300313
/// Called by SendPendingScreen when it consumes a resolution. Clears the published value.

Bitkit/Views/Contacts/AddContactView.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ struct AddContactView: View {
4040
NavigationBar(title: t("contacts__add_title"))
4141
.padding(.horizontal, 16)
4242

43-
if isLoading && fetchedProfile == nil {
43+
if isLoading {
4444
loadingContent
4545
} else if let profile = fetchedProfile {
4646
resultContent(profile)
@@ -259,8 +259,7 @@ struct AddContactView: View {
259259

260260
private func loadPaymentEndpoints(publicKey: String) async {
261261
do {
262-
let endpoints = try await PublicPaykitService.fetchPublicEndpoints(publicKey: publicKey)
263-
hasPublicPaymentEndpoint = !endpoints.isEmpty
262+
hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey)
264263
} catch {
265264
Logger.warn("Failed to load public payment endpoints for \(publicKey): \(error)", context: "AddContactView")
266265
hasPublicPaymentEndpoint = false
@@ -282,12 +281,21 @@ struct AddContactView: View {
282281
sheets: sheets
283282
)
284283

285-
if case .noEndpoint = result {
284+
switch result {
285+
case .opened:
286+
break
287+
case .noEndpoint:
286288
app.toast(
287289
type: .warning,
288290
title: t("slashtags__error_pay_title"),
289291
description: t("slashtags__error_pay_empty_msg")
290292
)
293+
case .notOpened:
294+
app.toast(
295+
type: .warning,
296+
title: t("slashtags__error_pay_title"),
297+
description: t("slashtags__error_pay_not_opened_msg")
298+
)
291299
}
292300
} catch {
293301
Logger.error("Failed to pay public pubky \(publicKey): \(error)", context: "AddContactView")

0 commit comments

Comments
 (0)