Skip to content

Commit 71150f9

Browse files
authored
Serialize iOS wallet operations and recover interrupted payments (#273)
1 parent 355db84 commit 71150f9

19 files changed

Lines changed: 1924 additions & 366 deletions

ios/CashuWallet.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
D200000000000109 /* WalletErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D100000000000109 /* WalletErrors.swift */; };
9595
D20000000000010A /* WalletManager+PendingMelts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000010A /* WalletManager+PendingMelts.swift */; };
9696
D20000000000010C /* PendingTokenClaim.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000010C /* PendingTokenClaim.swift */; };
97+
D20000000000010D /* WalletOperationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000010D /* WalletOperationCoordinator.swift */; };
9798
D200000000000201 /* PaymentMethodKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = D100000000000201 /* PaymentMethodKind.swift */; };
9899
D200000000000202 /* PaymentRequestParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = D100000000000202 /* PaymentRequestParser.swift */; };
99100
D200000000000203 /* OnchainExplorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D100000000000203 /* OnchainExplorer.swift */; };
@@ -255,6 +256,7 @@
255256
D100000000000109 /* WalletErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletErrors.swift; sourceTree = "<group>"; };
256257
D10000000000010A /* WalletManager+PendingMelts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletManager+PendingMelts.swift"; sourceTree = "<group>"; };
257258
D10000000000010C /* PendingTokenClaim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingTokenClaim.swift; sourceTree = "<group>"; };
259+
D10000000000010D /* WalletOperationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletOperationCoordinator.swift; sourceTree = "<group>"; };
258260
D100000000000201 /* PaymentMethodKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodKind.swift; sourceTree = "<group>"; };
259261
D100000000000202 /* PaymentRequestParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentRequestParser.swift; sourceTree = "<group>"; };
260262
D100000000000203 /* OnchainExplorer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnchainExplorer.swift; sourceTree = "<group>"; };
@@ -661,6 +663,7 @@
661663
D100000000000108 /* WalletManager+Backup.swift */,
662664
D100000000000109 /* WalletErrors.swift */,
663665
D10000000000010C /* PendingTokenClaim.swift */,
666+
D10000000000010D /* WalletOperationCoordinator.swift */,
664667
);
665668
path = Wallet;
666669
sourceTree = "<group>";
@@ -953,6 +956,7 @@
953956
D200000000000108 /* WalletManager+Backup.swift in Sources */,
954957
D200000000000109 /* WalletErrors.swift in Sources */,
955958
D20000000000010C /* PendingTokenClaim.swift in Sources */,
959+
D20000000000010D /* WalletOperationCoordinator.swift in Sources */,
956960
D200000000000201 /* PaymentMethodKind.swift in Sources */,
957961
D200000000000202 /* PaymentRequestParser.swift in Sources */,
958962
D200000000000203 /* OnchainExplorer.swift in Sources */,

ios/CashuWallet/Core/CashuRequestListener.swift

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ final class CashuRequestListener: ObservableObject {
7575
self.client = client
7676
await client.start()
7777
isRunning = true
78-
AppLogger.wallet.notice("CashuRequestListener: started on \(relays.count) relays, pubkey=\(String(pubkeyHex.prefix(8)), privacy: .public), since=\(since)")
78+
AppLogger.wallet.notice(
79+
"CashuRequestListener: started relays=\(relays.count, privacy: .public) pubkey=\(WalletOperationCoordinator.privacySafeIdentifier(pubkeyHex), privacy: .public) since=\(since, privacy: .public)"
80+
)
7981
}
8082

8183
func stop() async {
@@ -104,15 +106,19 @@ final class CashuRequestListener: ObservableObject {
104106
private func handle(event: NostrIncomingEvent, recipientPrivateKey: Data) async {
105107
guard event.kind == 1059 else { return }
106108
guard !processedIds.contains(event.id) else { return }
107-
AppLogger.wallet.notice("CashuRequestListener: gift wrap received id=\(String(event.id.prefix(8)), privacy: .public) createdAt=\(event.createdAt)")
109+
AppLogger.wallet.notice(
110+
"CashuRequestListener: gift wrap received event=\(WalletOperationCoordinator.privacySafeIdentifier(event.id), privacy: .public) created_at=\(event.createdAt, privacy: .public)"
111+
)
108112

109113
let rumor: NostrRumor
110114
do {
111115
rumor = try NIP17.unwrap(giftWrap: event, recipientPrivateKey: recipientPrivateKey)
112116
} catch {
113117
// Not encrypted for us (or an unrelated DM) — it can never succeed,
114118
// so mark it handled and stop reconsidering it.
115-
AppLogger.wallet.notice("CashuRequestListener: NIP-17 unwrap failed for \(String(event.id.prefix(8)), privacy: .public): \(String(describing: error), privacy: .public)")
119+
AppLogger.wallet.notice(
120+
"CashuRequestListener: NIP-17 unwrap failed event=\(WalletOperationCoordinator.privacySafeIdentifier(event.id), privacy: .public) error_type=\(String(reflecting: type(of: error)), privacy: .public)"
121+
)
116122
markProcessed(event.id)
117123
return
118124
}
@@ -190,16 +196,21 @@ final class CashuRequestListener: ObservableObject {
190196
do {
191197
// A gift wrap can arrive exactly as the app backgrounds; hold a background-task
192198
// assertion so this SQLite-writing redeem finishes before suspension.
193-
let amount = try await withBackgroundWriteAssertion("cashu-request-claim") {
199+
_ = try await withBackgroundWriteAssertion("cashu-request-claim") {
194200
try await walletManager.receiveCashuRequestPayment(
195201
tokenString: tokenString,
196202
requestId: requestId
197203
)
198204
}
199-
AppLogger.wallet.notice("CashuRequestListener: claimed \(amount) sat for request \(requestId ?? "", privacy: .public)")
205+
let requestHash = requestId.map(WalletOperationCoordinator.privacySafeIdentifier) ?? "none"
206+
AppLogger.wallet.notice(
207+
"CashuRequestListener: claimed payment request=\(requestHash, privacy: .public)"
208+
)
200209
return .claimed
201210
} catch {
202-
AppLogger.wallet.error("CashuRequestListener: redeem failed (will retry): \(String(describing: error), privacy: .public)")
211+
AppLogger.wallet.error(
212+
"CashuRequestListener: redeem failed (will retry) error_type=\(String(reflecting: type(of: error)), privacy: .public)"
213+
)
203214
return .transientFailure
204215
}
205216
}
@@ -254,7 +265,9 @@ final class CashuRequestListener: ObservableObject {
254265
)
255266
walletManager.savePendingReceiveToken(pending)
256267
heldForApproval = pending
257-
AppLogger.wallet.notice("CashuRequestListener: payment from \(mintUrl, privacy: .public) held for approval (\(reason, privacy: .public))")
268+
AppLogger.wallet.notice(
269+
"CashuRequestListener: payment held for approval resource=\(WalletOperationCoordinator.privacySafeIdentifier(mintUrl), privacy: .public) reason=\(reason, privacy: .public)"
270+
)
258271
Task { await walletManager.loadTransactions() }
259272
return .held
260273
}
@@ -268,7 +281,9 @@ final class CashuRequestListener: ObservableObject {
268281
try await walletManager.claimPendingReceiveToken(pending)
269282
}
270283
if heldForApproval?.tokenId == pending.tokenId { heldForApproval = nil }
271-
AppLogger.wallet.notice("CashuRequestListener: user approved claim of \(amount) sat from \(pending.mintUrl, privacy: .public)")
284+
AppLogger.wallet.notice(
285+
"CashuRequestListener: user approved claim resource=\(WalletOperationCoordinator.privacySafeIdentifier(pending.mintUrl), privacy: .public)"
286+
)
272287
await claimEligibleHeldPayments()
273288
return amount
274289
}
@@ -277,7 +292,9 @@ final class CashuRequestListener: ObservableObject {
277292
func declineHeldPayment(_ pending: PendingReceiveToken) {
278293
walletManager?.removePendingReceiveToken(tokenId: pending.tokenId)
279294
if heldForApproval?.tokenId == pending.tokenId { heldForApproval = nil }
280-
AppLogger.wallet.notice("CashuRequestListener: user declined payment from \(pending.mintUrl, privacy: .public)")
295+
AppLogger.wallet.notice(
296+
"CashuRequestListener: user declined payment resource=\(WalletOperationCoordinator.privacySafeIdentifier(pending.mintUrl), privacy: .public)"
297+
)
281298
Task { await walletManager?.loadTransactions() }
282299
}
283300

@@ -300,13 +317,17 @@ final class CashuRequestListener: ObservableObject {
300317
}
301318
for pending in eligible {
302319
do {
303-
let amount = try await withBackgroundWriteAssertion("cashu-request-claim") {
320+
_ = try await withBackgroundWriteAssertion("cashu-request-claim") {
304321
try await walletManager.claimPendingReceiveToken(pending)
305322
}
306323
if heldForApproval?.tokenId == pending.tokenId { heldForApproval = nil }
307-
AppLogger.wallet.notice("CashuRequestListener: claimed held payment of \(amount) sat from \(pending.mintUrl, privacy: .public)")
324+
AppLogger.wallet.notice(
325+
"CashuRequestListener: claimed held payment resource=\(WalletOperationCoordinator.privacySafeIdentifier(pending.mintUrl), privacy: .public)"
326+
)
308327
} catch {
309-
AppLogger.wallet.error("CashuRequestListener: held-payment claim failed (stays claimable in History): \(String(describing: error), privacy: .public)")
328+
AppLogger.wallet.error(
329+
"CashuRequestListener: held-payment claim failed (stays claimable in History) error_type=\(String(reflecting: type(of: error)), privacy: .public)"
330+
)
310331
}
311332
}
312333
}

ios/CashuWallet/Core/NWCManager.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@ final class NWCManager: ObservableObject {
171171
isRunning = false
172172
service = nil
173173
errorMessage = error.userFacingWalletMessage
174-
AppLogger.wallet.error("Failed to start NWC service: \(String(describing: error))")
174+
AppLogger.wallet.error(
175+
"NWC service start failed error_type=\(String(reflecting: type(of: error)), privacy: .public)"
176+
)
175177
}
176178
}
177179

@@ -225,7 +227,9 @@ final class NWCManager: ObservableObject {
225227
do {
226228
try await service.stop()
227229
} catch {
228-
AppLogger.wallet.error("Failed to stop NWC service: \(String(describing: error))")
230+
AppLogger.wallet.error(
231+
"NWC service stop failed error_type=\(String(reflecting: type(of: error)), privacy: .public)"
232+
)
229233
}
230234
}
231235
service = nil

0 commit comments

Comments
 (0)