Skip to content

Commit b8a3078

Browse files
committed
fix: retry superseded public invoice preparation
1 parent aac9d0b commit b8a3078

3 files changed

Lines changed: 196 additions & 24 deletions

File tree

‎Bitkit/ViewModels/WalletViewModel.swift‎

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,13 +1401,9 @@ class WalletViewModel: ObservableObject {
14011401
return ("", "")
14021402
}
14031403

1404-
let invoiceRevision = publicPaykitInvoiceRevision
1404+
let initialInvoiceRevision = publicPaykitInvoiceRevision
14051405
let session = pubkyProfile?.currentSession
14061406
let wasSharing = sharesPublicPaykitEndpoints
1407-
let canCommitInvoice = {
1408-
!Task.isCancelled && self.isPaykitUIActive && self.publicPaykitInvoiceRevision == invoiceRevision &&
1409-
self.pubkyProfile?.currentSession == session && self.sharesPublicPaykitEndpoints == wasSharing
1410-
}
14111407
let receiveID = receiveRefreshID
14121408
let commitID = receiveCommitID
14131409
let previousAddress = onchainAddress
@@ -1416,26 +1412,46 @@ class WalletViewModel: ObservableObject {
14161412
onchainAddress = publicOnchainAddress
14171413
}
14181414

1419-
if includeLightning, hasUsableChannels {
1420-
let hasReusableInvoice = await hasReusablePublicPaykitInvoice()
1421-
guard canCommitInvoice() else { throw PubkyServiceError.sessionNotActive }
1422-
let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice
1423-
if shouldRefreshBolt11 {
1424-
do {
1425-
try await refreshPublicPaykitBolt11(canCommitInvoice: canCommitInvoice)
1426-
} catch let error as PublicPaykitError {
1427-
guard case .routeHintsUnavailable = error else {
1428-
throw error
1415+
for attempt in 0 ..< 2 {
1416+
let invoiceRevision = attempt == 0 ? initialInvoiceRevision : publicPaykitInvoiceRevision
1417+
let canCommitInvoice = { () async throws -> Bool in
1418+
if attempt > 0 {
1419+
guard await !PrivatePaykitService.shared.isDeletingProfile else { throw PubkyServiceError.sessionNotActive }
1420+
}
1421+
guard !Task.isCancelled, self.isPaykitUIActive,
1422+
attempt == 0 || self.pubkyProfile?.isDeletingProfile != true,
1423+
self.pubkyProfile?.currentSession == session && self.sharesPublicPaykitEndpoints == wasSharing
1424+
else { throw PubkyServiceError.sessionNotActive }
1425+
return self.publicPaykitInvoiceRevision == invoiceRevision
1426+
}
1427+
if attempt > 0 {
1428+
guard try await canCommitInvoice() else { continue }
1429+
}
1430+
if includeLightning, hasUsableChannels {
1431+
let hasReusableInvoice = await hasReusablePublicPaykitInvoice()
1432+
guard try await canCommitInvoice() else { continue }
1433+
let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice
1434+
if shouldRefreshBolt11 {
1435+
do {
1436+
guard try await refreshPublicPaykitBolt11(canCommitInvoice: canCommitInvoice) else { continue }
1437+
} catch let error as PublicPaykitError {
1438+
guard case .routeHintsUnavailable = error else {
1439+
throw error
1440+
}
1441+
Logger.warn(
1442+
"Public Paykit Lightning invoice has no route hints; publishing on-chain endpoint only",
1443+
context: "WalletViewModel"
1444+
)
14291445
}
1430-
Logger.warn("Public Paykit Lightning invoice has no route hints; publishing on-chain endpoint only", context: "WalletViewModel")
14311446
}
1447+
} else if includeLightning {
1448+
guard try await canCommitInvoice() else { continue }
1449+
clearPublicPaykitBolt11()
14321450
}
1433-
} else if includeLightning {
1434-
guard canCommitInvoice() else { throw PubkyServiceError.sessionNotActive }
1435-
clearPublicPaykitBolt11()
1436-
}
14371451

1438-
return (publicOnchainAddress, includeLightning ? publicPaykitBolt11 : "")
1452+
return (publicOnchainAddress, includeLightning ? publicPaykitBolt11 : "")
1453+
}
1454+
throw PublicPaykitError.publicationFailed
14391455
}
14401456

14411457
func refreshPublicPaykitEndpointsOnForeground(isSessionCurrent: @escaping @MainActor () -> Bool) async {
@@ -1515,10 +1531,10 @@ class WalletViewModel: ObservableObject {
15151531
PublicPaykitService.hasLightningRouteHints(bolt11: publicPaykitBolt11)
15161532
}
15171533

1518-
private func refreshPublicPaykitBolt11(canCommitInvoice: () -> Bool) async throws {
1534+
private func refreshPublicPaykitBolt11(canCommitInvoice: () async throws -> Bool) async throws -> Bool {
15191535
let invoice = try await createInvoice(amountSats: nil, note: "")
15201536
let decodedInvoice = try await decode(invoice: invoice)
1521-
guard canCommitInvoice() else { throw PubkyServiceError.sessionNotActive }
1537+
guard try await canCommitInvoice() else { return false }
15221538
guard case let .lightning(lightningInvoice) = decodedInvoice else {
15231539
clearPublicPaykitBolt11()
15241540
throw PublicPaykitError.invalidPayload
@@ -1531,6 +1547,7 @@ class WalletViewModel: ObservableObject {
15311547
publicPaykitBolt11 = invoice
15321548
publicPaykitBolt11PaymentHash = lightningInvoice.paymentHash.hex
15331549
publicPaykitBolt11ExpiresAt = Double(lightningInvoice.timestampSeconds + lightningInvoice.expirySeconds)
1550+
return true
15341551
}
15351552

15361553
private func clearPublicPaykitBolt11() {

‎BitkitTests/ReceiveInvoiceRefreshTests.swift‎

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,152 @@ final class ReceiveInvoiceRefreshTests: XCTestCase {
246246
} catch PublicPaykitError.publicationFailed {}
247247
}
248248

249+
func testEnablingContactPaymentsRetriesInvoiceSupersessionOnce() async throws {
250+
snapshotAppDefaultsDomain()
251+
UserDefaults.standard.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key)
252+
for duringCreation in [false, true] {
253+
for invalidations in duringCreation ? [1, 2] : [1] {
254+
let (profile, wallet) = publicationWallet(route: .channelUsable)
255+
wallet.stubPublicEndpoints = false
256+
UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey)
257+
UserDefaults.standard.set(false, forKey: ContactPaymentsService.confirmedPreferenceKey)
258+
let session = try XCTUnwrap(profile.currentSession)
259+
var preparations = 0
260+
let invalidate = {
261+
preparations += 1
262+
if preparations <= invalidations {
263+
wallet.publicPaykitBolt11PaymentHash = "paid"
264+
wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid")
265+
}
266+
}
267+
wallet.beforeReceiveAddress = { if !duringCreation { invalidate() } }
268+
wallet.invoiceCreation = {
269+
if duringCreation { invalidate() }
270+
return Self.invoiceWithRouteHints
271+
}
272+
var published: [String] = []
273+
var removals = 0
274+
let operations = ContactPaymentsService.Operations(
275+
syncPublicEndpoints: { publish, isCurrent in
276+
try await PublicPaykitService.syncPublishedEndpoints(
277+
wallet: wallet, publish: publish, isSessionCurrent: isCurrent, syncApp: {},
278+
applyEndpoints: {
279+
if !publish { removals += 1 }
280+
published = $0.map(\.value)
281+
}
282+
)
283+
},
284+
preparePrivateEndpoints: { _, _, _ in nil }, removePrivateEndpoints: { _ in },
285+
setPublicCleanupPending: PublicPaykitService.setCleanupPending,
286+
setPrivateCleanupPending: PrivatePaykitService.setContactSharingCleanupPending
287+
)
288+
do {
289+
let applied = try await ContactPaymentsService.setEnabled(
290+
true, pubkyProfile: profile, contactsManager: ContactsManager(),
291+
operations: operations, privatePaymentAccess: { _ in false }
292+
)
293+
XCTAssertEqual(invalidations, 1)
294+
XCTAssertTrue(applied)
295+
XCTAssertTrue(published.contains(Self.invoiceWithRouteHints))
296+
} catch {
297+
XCTAssertEqual(invalidations, 2, "\(error), creation: \(duringCreation)")
298+
guard case PublicPaykitError.publicationFailed = error else {
299+
XCTFail("Expected publication failure, got \(error)")
300+
continue
301+
}
302+
XCTAssertEqual(preparations, 2)
303+
XCTAssertTrue(published.isEmpty)
304+
}
305+
XCTAssertEqual(profile.currentSession, session)
306+
XCTAssertEqual(ContactPaymentsService.isEnabled(), invalidations == 1)
307+
XCTAssertEqual(removals, invalidations == 1 ? 0 : 1)
308+
}
309+
}
310+
}
311+
312+
func testInvoiceSupersessionDoesNotRetryCreationOrDecodeFailures() async throws {
313+
for failsCreation in [false, true] {
314+
let (_, wallet) = publicationWallet(route: .channelUsable)
315+
wallet.stubPublicEndpoints = false
316+
var creations = 0
317+
wallet.invoiceCreation = {
318+
creations += 1
319+
wallet.publicPaykitBolt11PaymentHash = "paid"
320+
wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid")
321+
if failsCreation { throw PublicPaykitError.walletNotReady }
322+
return "invalid-invoice"
323+
}
324+
do {
325+
_ = try await wallet.refreshPublicPaykitEndpoints(includeOnchain: false)
326+
XCTFail("Expected invoice failure")
327+
} catch {
328+
if failsCreation {
329+
guard case PublicPaykitError.walletNotReady = error else { return XCTFail("Unexpected error: \(error)") }
330+
} else {
331+
XCTAssertFalse(error is PubkyServiceError)
332+
XCTAssertFalse(error is PublicPaykitError)
333+
}
334+
}
335+
XCTAssertEqual(creations, 1)
336+
XCTAssertTrue(wallet.publicPaykitBolt11.isEmpty)
337+
}
338+
}
339+
340+
func testEnablingContactPaymentsRejectsDeletionBeforeOrDuringInvoiceRetry() async throws {
341+
snapshotAppDefaultsDomain()
342+
UserDefaults.standard.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key)
343+
for heldCreation in [1, 2] {
344+
let (profile, wallet) = publicationWallet(route: .channelUsable)
345+
wallet.stubPublicEndpoints = false
346+
UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey)
347+
UserDefaults.standard.set(false, forKey: ContactPaymentsService.confirmedPreferenceKey)
348+
let session = try XCTUnwrap(profile.currentSession)
349+
let creation = PublicationGate("Public invoice creation before deletion")
350+
var creations = 0
351+
wallet.invoiceCreation = {
352+
creations += 1
353+
if creations == 1 {
354+
wallet.publicPaykitBolt11PaymentHash = "paid"
355+
wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid")
356+
}
357+
if creations == heldCreation { await creation.wait() }
358+
return Self.invoiceWithRouteHints
359+
}
360+
let operations = ContactPaymentsService.Operations(
361+
syncPublicEndpoints: { publish, isCurrent in
362+
try await PublicPaykitService.syncPublishedEndpoints(
363+
wallet: wallet, publish: publish, isSessionCurrent: isCurrent, syncApp: {},
364+
applyEndpoints: { XCTAssertTrue($0.isEmpty, "Deletion must not publish replacement endpoints") }
365+
)
366+
},
367+
preparePrivateEndpoints: { _, _, _ in nil }, removePrivateEndpoints: { _ in },
368+
setPublicCleanupPending: PublicPaykitService.setCleanupPending,
369+
setPrivateCleanupPending: PrivatePaykitService.setContactSharingCleanupPending
370+
)
371+
let change = Task {
372+
try await ContactPaymentsService.setEnabled(
373+
true, pubkyProfile: profile, contactsManager: ContactsManager(),
374+
operations: operations, privatePaymentAccess: { _ in false }
375+
)
376+
}
377+
await fulfillment(of: [creation.started], timeout: 5)
378+
await profile.performProfileDeletion {
379+
XCTAssertEqual(profile.currentSession, session)
380+
creation.release()
381+
do {
382+
_ = try await change.value
383+
XCTFail("Expected deletion to reject invoice retry")
384+
} catch PubkyServiceError.sessionNotActive {
385+
} catch {
386+
XCTFail("Unexpected error: \(error)")
387+
}
388+
XCTAssertTrue(wallet.publicPaykitBolt11.isEmpty)
389+
XCTAssertFalse(ContactPaymentsService.isEnabled())
390+
XCTAssertEqual(creations, heldCreation)
391+
}
392+
}
393+
}
394+
249395
func testAdmittedPublicWriteCannotAcknowledgeNewerChannelRefresh() async throws {
250396
for invoice in ["ln-public", ""] {
251397
let (profile, wallet) = publicationWallet(route: .channelUsable)
@@ -299,7 +445,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase {
299445
}
300446

301447
func testSuspendedPublicInvoiceCreationCannotMutateChangedOwnerOrSharing() async throws {
302-
enum State: CaseIterable { case replaced, sharingDisabled, cancelled }
448+
enum State: CaseIterable { case replaced, sameKeyNewSession, sharingDisabled, uiDisabled, cancelled }
303449
for state in State.allCases {
304450
let (profile, wallet) = publicationWallet(route: .channelUsable)
305451
wallet.stubPublicEndpoints = false
@@ -310,9 +456,16 @@ final class ReceiveInvoiceRefreshTests: XCTestCase {
310456
}
311457
let preparation = Task { try await wallet.refreshPublicPaykitEndpoints(includeOnchain: false) }
312458
await fulfillment(of: [creation.started], timeout: 5)
459+
wallet.publicPaykitBolt11PaymentHash = "paid"
460+
wallet.rotatePublicPaykitInvoiceIfNeeded(paymentHash: "paid")
313461
switch state {
314462
case .replaced: profile.publicKey = "pubky" + String(repeating: "z", count: 52)
463+
case .sameKeyNewSession:
464+
do {
465+
try await profile.signOut(performSessionCleanup: { throw PublicPaykitError.publicationFailed })
466+
} catch PublicPaykitError.publicationFailed {}
315467
case .sharingDisabled: UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey)
468+
case .uiDisabled: UserDefaults.standard.set(false, forKey: PaykitFeatureFlags.uiEnabledKey)
316469
case .cancelled: preparation.cancel()
317470
}
318471
wallet.publicPaykitBolt11 = "ln-retained"

‎journeys/profile/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ While deletion is held, make a Lightning channel usable or ready, then fail dele
2020

2121
Repeat the held snapshot check while enabling contact payments and while a failed enable restores the previous sharing preference. A late stale publication must remain pending for repair; a successful current publication must acknowledge earlier pending work. Explicit disabling must still remove endpoints and retain failed cleanup for retry.
2222

23+
While enabling contact payments, invalidate the public invoice during address preparation or invoice creation without changing the session. Enabling must re-prepare once and keep sharing enabled after publication. Repeated invalidation must stop with a publication failure, while creation or decoding errors must propagate without retry. Cancellation, identity changes, disabled sharing or UI, and active deletion must not admit replacement preparation. These checks require controlled preparation and invalidation gates beyond the journey runner capabilities.
24+
2325
On iOS, these channel-refresh checks cover public endpoints. Private endpoint preparation retains its existing cleanup and reconciliation behavior.
2426

2527
Hold initial session restoration before sign-in completes, make a Lightning channel usable or ready, then release restoration while public publication remains blocked. Restore readiness or connectivity without another channel event. Normal public maintenance must retain the forced invoice refresh for the restored identity, including after a deferred or failed restoration and a skipped or failed publication. Work retained for the stored identity or an existing signed-in session must not transfer to a replacement identity or a new session for the same key. These checks require restoration, channel and publication gates beyond the journey runner capabilities.

0 commit comments

Comments
 (0)