Skip to content

Commit 778f6d0

Browse files
committed
fix: harden paykit request payments
1 parent 53075f0 commit 778f6d0

14 files changed

Lines changed: 408 additions & 84 deletions

Bitkit/AppScene.swift

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,17 @@ struct AppScene: View {
227227
guard configuration == nil else { return }
228228
Task { await presentNextIncomingPaykitPaymentRequest() }
229229
}
230+
.onChange(of: paykitPaymentRequestManager.pendingRequests) { _, requests in
231+
guard let request = app.contactPaymentContext?.incomingPaymentRequest,
232+
request.isExpired(at: Date()),
233+
!requests.contains(where: { $0.id == request.id }),
234+
sheets.activeSheetConfiguration?.id == .send
235+
else { return }
236+
237+
app.resetSendState()
238+
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
239+
sheets.hideSheetIfActive(.send, reason: "Incoming payment request expired")
240+
}
230241
.onChange(of: navigation.currentRoute) { oldRoute, newRoute in
231242
guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else {
232243
return
@@ -740,37 +751,71 @@ struct AppScene: View {
740751
private func presentNextIncomingPaykitPaymentRequest() async {
741752
guard !isPresentingPaykitPaymentRequest,
742753
sheets.activeSheetConfiguration == nil,
743-
let request = paykitPaymentRequestManager.nextRequestForPresentation()
754+
app.contactPaymentContext == nil
744755
else { return }
745756

757+
let requests = paykitPaymentRequestManager.requestsForPresentation()
758+
guard !requests.isEmpty else { return }
759+
746760
isPresentingPaykitPaymentRequest = true
747761
defer { isPresentingPaykitPaymentRequest = false }
748762

749-
do {
750-
let result = try await PrivatePaykitService.shared.beginPaymentRequest(request, wallet: wallet)
751-
guard case let .opened(paymentTarget, privatePaymentContext) = result,
752-
sheets.activeSheetConfiguration == nil
753-
else { return }
754-
755-
try await app.handleScannedData(paymentTarget)
756-
guard sheets.activeSheetConfiguration == nil,
757-
PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil
758-
else { return }
759-
760-
app.contactPaymentContext = ContactPaymentContext(
761-
publicKey: request.counterparty,
762-
privatePaymentContext: privatePaymentContext,
763-
incomingPaymentRequest: request
764-
)
765-
wallet.sendAmountSats = request.amountSats
766-
paykitPaymentRequestManager.markPresented(request)
763+
for request in requests {
764+
do {
765+
let result = try await PrivatePaykitService.shared.beginPaymentRequest(request)
766+
guard sheets.activeSheetConfiguration == nil, app.contactPaymentContext == nil else { return }
767+
guard case let .opened(paymentTarget, privatePaymentContext) = result else { continue }
768+
769+
let contactPaymentContext = ContactPaymentContext(
770+
publicKey: request.counterparty,
771+
privatePaymentContext: privatePaymentContext,
772+
incomingPaymentRequest: request
773+
)
774+
guard app.claimContactPaymentContext(contactPaymentContext) else { return }
775+
776+
do {
777+
try await app.handleScannedData(
778+
paymentTarget,
779+
claimedContactPaymentContext: contactPaymentContext
780+
)
781+
guard app.ownsContactPaymentContext(contactPaymentContext),
782+
sheets.activeSheetConfiguration == nil
783+
else { return }
784+
guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else {
785+
app.resetSendState()
786+
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
787+
continue
788+
}
767789

768-
let route: SendRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm
769-
sheets.showSheet(.send, data: SendConfig(view: route))
770-
} catch is CancellationError {
771-
return
772-
} catch {
773-
Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene")
790+
guard paykitPaymentRequestManager.markPresentedIfPending(request) else {
791+
app.resetSendState()
792+
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
793+
continue
794+
}
795+
} catch is CancellationError {
796+
if app.ownsContactPaymentContext(contactPaymentContext) {
797+
app.resetSendState()
798+
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
799+
}
800+
return
801+
} catch {
802+
guard app.ownsContactPaymentContext(contactPaymentContext) else { return }
803+
Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene")
804+
app.resetSendState()
805+
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
806+
continue
807+
}
808+
809+
wallet.sendAmountSats = request.amountSats
810+
811+
let route: SendRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm
812+
sheets.showSheet(.send, data: SendConfig(view: route))
813+
return
814+
} catch is CancellationError {
815+
return
816+
} catch {
817+
Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene")
818+
}
774819
}
775820
}
776821

Bitkit/Services/PaykitPaymentRequestService.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ struct PaykitPaymentRequest: Identifiable, Equatable {
3232
let terms = record.terms,
3333
terms.recurrence == nil,
3434
terms.amount.asset == "btc",
35-
let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value)
35+
let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value),
36+
amountSats <= UInt64.max / 1000
3637
else { return nil }
3738

3839
let acceptedPaymentEndpointIdentifiers = Self.supportedEndpointIdentifiers(
@@ -65,6 +66,16 @@ struct PaykitPaymentRequest: Identifiable, Equatable {
6566
expiresAt.map { $0 <= date } ?? false
6667
}
6768

69+
func acceptsLightningInvoiceAmount(milliSatoshis: UInt64?) -> Bool {
70+
guard let milliSatoshis else { return true }
71+
let (requestedMilliSatoshis, overflow) = amountSats.multipliedReportingOverflow(by: 1000)
72+
return !overflow && milliSatoshis == requestedMilliSatoshis
73+
}
74+
75+
func acceptsPaymentAmount(_ amountSats: UInt64) -> Bool {
76+
amountSats == self.amountSats
77+
}
78+
6879
private static func supportedEndpointIdentifiers(_ identifiers: [String]) -> [String] {
6980
var seen = Set<String>()
7081
return identifiers.filter { identifier in
@@ -261,13 +272,15 @@ final class PaykitPaymentRequestManager {
261272
presentedRequestIds = []
262273
}
263274

264-
func nextRequestForPresentation() -> PaykitPaymentRequest? {
265-
pendingRequests.first { !presentedRequestIds.contains($0.id) }
275+
func requestsForPresentation() -> [PaykitPaymentRequest] {
276+
pendingRequests.filter { !presentedRequestIds.contains($0.id) }
266277
}
267278

268-
func markPresented(_ request: PaykitPaymentRequest) {
269-
guard pendingRequests.contains(where: { $0.id == request.id }) else { return }
279+
func markPresentedIfPending(_ request: PaykitPaymentRequest) -> Bool {
280+
discardExpiredRequests()
281+
guard pendingRequests.contains(where: { $0.id == request.id }) else { return false }
270282
presentedRequestIds.insert(request.id)
283+
return true
271284
}
272285

273286
private func performRefresh(generation: Int) async {

Bitkit/Services/PrivatePaykitService+Contacts.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,10 @@ extension PrivatePaykitService {
177177
let savedKeys = Set(normalizedSavedContactKeys(publicKeys))
178178
knownSavedContactKeys = savedKeys
179179

180-
let staleKeys = Set(state.contacts.keys).subtracting(savedKeys)
180+
let staleKeys: Set<String> = Set(state.contacts.compactMap { publicKey, contactState in
181+
guard !savedKeys.contains(publicKey), contactState.hasContactOwnedCacheState else { return nil }
182+
return publicKey
183+
})
181184
let cleanupKeys = staleKeys.union(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys))
182185
guard !cleanupKeys.isEmpty else { return }
183186

@@ -485,11 +488,11 @@ extension PrivatePaykitService {
485488
}
486489
}
487490

488-
func schedulePrivatePaymentRecovery(for publicKey: String) {
491+
func schedulePrivatePaymentRecovery(for publicKey: String, receiverPath: String) {
489492
guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { return }
490493
schedulePendingPrivateMessageDrainRetries(
491494
reason: "payment recovery",
492-
retryKeys: [PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: PaykitReceiverPath.wallet)]
495+
retryKeys: [PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)]
493496
)
494497
}
495498

Bitkit/Services/PrivatePaykitService+Models.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ extension PrivatePaykitService {
2121
!localInvoicesByReceiverPath.isEmpty ||
2222
!receivedInvoicePaymentHashes.isEmpty
2323
}
24+
25+
var hasContactOwnedCacheState: Bool {
26+
!publishedPrivatePaymentReceiverPaths.isEmpty ||
27+
!cachedResolvedEndpoints.isEmpty ||
28+
!localInvoicesByReceiverPath.isEmpty ||
29+
!receivedInvoicePaymentHashes.isEmpty
30+
}
2431
}
2532

2633
struct StoredPaymentEntry: Codable {

Bitkit/Services/PrivatePaykitService+Payments.swift

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,25 @@ extension PrivatePaykitService {
1818
return try await PublicPaykitService.beginPayment(to: publicKey)
1919
}
2020

21-
return try await beginContactPayment(to: normalizedKey, receiverPath: PaykitReceiverPath.wallet, wallet: wallet)
21+
if await canPublishPrivateEndpoints(wallet: wallet) {
22+
_ = await refreshSavedContactEndpointsReturningError(
23+
for: [normalizedKey],
24+
wallet: wallet,
25+
forceRefreshLightning: false,
26+
requireImmediatePublication: false
27+
)
28+
}
29+
30+
var result = try await beginContactPayment(to: normalizedKey, receiverPath: PaykitReceiverPath.wallet)
31+
for delay in Self.privatePaymentResolutionRetryDelays {
32+
guard case .waitingForUpdatedPaymentList = result else { return result }
33+
try await Task.sleep(nanoseconds: delay)
34+
result = try await beginContactPayment(to: normalizedKey, receiverPath: PaykitReceiverPath.wallet)
35+
}
36+
return result
2237
}
2338

24-
func beginPaymentRequest(_ request: PaykitPaymentRequest, wallet: WalletViewModel) async throws -> PublicPaykitPaymentLaunchResult {
39+
func beginPaymentRequest(_ request: PaykitPaymentRequest) async throws -> PublicPaykitPaymentLaunchResult {
2540
guard !request.isExpired(at: Date()) else {
2641
throw PaykitPaymentRequestError.requestExpired
2742
}
@@ -32,33 +47,15 @@ extension PrivatePaykitService {
3247
return try await beginContactPayment(
3348
to: publicKey,
3449
receiverPath: request.counterpartyReceiverPath,
35-
paymentRequest: request,
36-
wallet: wallet
50+
paymentRequest: request
3751
)
3852
}
3953

4054
private func beginContactPayment(
4155
to publicKey: String,
4256
receiverPath: String,
43-
paymentRequest: PaykitPaymentRequest? = nil,
44-
wallet: WalletViewModel
57+
paymentRequest: PaykitPaymentRequest? = nil
4558
) async throws -> PublicPaykitPaymentLaunchResult {
46-
guard try await hasLiveSessionForCurrentProfile() else {
47-
if paymentRequest != nil {
48-
throw PrivatePaykitError.privateUnavailable
49-
}
50-
return try await PublicPaykitService.beginPayment(to: publicKey)
51-
}
52-
53-
if paymentRequest == nil, await canPublishPrivateEndpoints(wallet: wallet) {
54-
_ = await refreshSavedContactEndpointsReturningError(
55-
for: [publicKey],
56-
wallet: wallet,
57-
forceRefreshLightning: false,
58-
requireImmediatePublication: false
59-
)
60-
}
61-
6259
let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath]
6360
let amount = paymentRequest.map {
6461
PaymentAmountContext(value: $0.amountValue, asset: "btc")
@@ -102,11 +99,11 @@ extension PrivatePaykitService {
10299
}
103100

104101
if resolution.state == .recoveryPending {
105-
schedulePrivatePaymentRecovery(for: publicKey)
102+
schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath)
106103
}
107104

108105
if resolution.status == .waitingForUpdatedPaymentList {
109-
schedulePrivatePaymentRecovery(for: publicKey)
106+
schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath)
110107
return .waitingForUpdatedPaymentList
111108
}
112109

@@ -287,3 +284,9 @@ extension PrivatePaykitService {
287284
}
288285
}
289286
}
287+
288+
private extension PrivatePaykitService {
289+
static var privatePaymentResolutionRetryDelays: ArraySlice<UInt64> {
290+
privateMessageDrainRetryDelays.prefix(3)
291+
}
292+
}

Bitkit/Services/PrivatePaykitService+State.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ extension PrivatePaykitService {
1818

1919
func clearContactState(publicKey: String) async {
2020
guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return }
21-
state.contacts[normalizedKey] = nil
21+
let consumedVersions = state.contacts[normalizedKey]?.consumedPrivatePaymentListVersionsByReceiverPath ?? [:]
22+
if consumedVersions.isEmpty {
23+
state.contacts[normalizedKey] = nil
24+
} else {
25+
var contactState = ContactState()
26+
contactState.consumedPrivatePaymentListVersionsByReceiverPath = consumedVersions
27+
state.contacts[normalizedKey] = contactState
28+
}
2229
await PrivatePaykitAddressReservationStore.shared.clearContactAssignment(publicKey: normalizedKey)
2330
persistState(markWalletBackup: true)
2431
}

0 commit comments

Comments
 (0)