damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit fe52381d6373b2075013596c4266d0705b408aa4
parent ab8d52e685533022a6216eb141b8cc7bd6960f6e
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Mon,  5 May 2025 15:53:37 -0700

Improve error handling on NWC wallet

Changelog-Changed: Added more human visible errors on NWC wallets to aid with troubleshooting
Changelog-Added: Added copy technical info button to user visible errors, so that users can more easily share errors with developers
Closes: https://github.com/damus-io/damus/issues/3010
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 8++++++++
Mdamus/Models/HomeModel.swift | 27++++++++++++++++++++-------
Mdamus/NIP04/NIP04.swift | 24++++++++++++++++++++++++
Mdamus/Nostr/NostrEvent.swift | 5+++++
Adamus/Util/WalletConnect/HumanReadableErrors.swift | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Util/WalletConnect/Response.swift | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mdamus/Views/ErrorHandling/ErrorView.swift | 39++++++++++++++++++++++++++++++++++++++-
7 files changed, 259 insertions(+), 28 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -1479,6 +1479,9 @@ D74AAFD22B155E78006CF0F4 /* WalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09612A098D0E00943473 /* WalletConnect.swift */; }; D74AAFD42B155ECB006CF0F4 /* Zaps+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */; }; D74AAFD62B155F0C006CF0F4 /* WalletConnect+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */; }; + D74E64132DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; }; + D74E64142DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; }; + D74E64152DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; }; D74EA08A2D2BF2A7002290DD /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D767066E2C8BB3CE00F09726 /* URLHandler.swift */; }; D74EA08E2D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; }; D74EA08F2D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; }; @@ -2517,6 +2520,7 @@ D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapDataModel.swift; sourceTree = "<group>"; }; D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Zaps+.swift"; sourceTree = "<group>"; }; D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletConnect+.swift"; sourceTree = "<group>"; }; + D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HumanReadableErrors.swift; sourceTree = "<group>"; }; D74EA08D2D2E271E002290DD /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; }; D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventView.swift; sourceTree = "<group>"; }; D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = "<group>"; }; @@ -4040,6 +4044,7 @@ D78F080A2D7F78B000FC6C75 /* WalletConnect */ = { isa = PBXGroup; children = ( + D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */, D78F08102D7F78F600FC6C75 /* Response.swift */, D78F080B2D7F78EB00FC6C75 /* Request.swift */, 4C7D09612A098D0E00943473 /* WalletConnect.swift */, @@ -4694,6 +4699,7 @@ D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */, D7373BA82B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift in Sources */, 4C7D09682A0AE9B200943473 /* NWCScannerView.swift in Sources */, + D74E64152DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */, D7CB5D452B116FE800AD4105 /* Contacts+.swift in Sources */, 4CA352A42A76AFF3003BB08B /* UpdateStatsNotify.swift in Sources */, D798D21E2B0858BB00234419 /* MigratedTypes.swift in Sources */, @@ -5131,6 +5137,7 @@ 82D6FAED2CD99F7900C925F4 /* PostNotify.swift in Sources */, 82D6FAEE2CD99F7900C925F4 /* PresentSheetNotify.swift in Sources */, D74EA0932D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */, + D74E64142DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */, 82D6FAEF2CD99F7900C925F4 /* ProfileUpdatedNotify.swift in Sources */, 82D6FAF02CD99F7900C925F4 /* ReportNotify.swift in Sources */, 82D6FAF12CD99F7900C925F4 /* ScrollToTopNotify.swift in Sources */, @@ -5739,6 +5746,7 @@ D73E5ECC2C6A97F4007EB227 /* SuggestedUsersViewModel.swift in Sources */, D73E5ED22C6A97F4007EB227 /* WalletView.swift in Sources */, D73E5ED32C6A97F4007EB227 /* NWCScannerView.swift in Sources */, + D74E64132DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */, D73E5ED42C6A97F4007EB227 /* FriendsButton.swift in Sources */, D73E5ED52C6A97F4007EB227 /* GradientFollowButton.swift in Sources */, D73E5ED62C6A97F4007EB227 /* AlbyButton.swift in Sources */, diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -261,23 +261,36 @@ class HomeModel: ContactsDelegate { Task { @MainActor in // TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time guard let nwc_str = damus_state.settings.nostr_wallet_connect, - let nwc = WalletConnectURL(str: nwc_str), - let resp = await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) else { - Log.error("HomeModel: Received NWC response I do not understand", for: .nwc) + let nwc = WalletConnectURL(str: nwc_str) else { return } - + + var resp: WalletConnect.FullWalletResponse? = nil + do { + resp = try await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) + } catch { + Log.error("HomeModel: Error on NWC wallet response handling: %s", for: .nwc, error.localizedDescription) + if let initError = error as? WalletConnect.FullWalletResponse.InitializationError, + let humanReadableError = initError.humanReadableError { + present_sheet(.error(humanReadableError)) + } + } + guard let resp else { return } + // since command results are not returned for ephemeral events, // remove the request from the postbox which is likely failing over and over if damus_state.nostrNetwork.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) { - print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]") + Log.debug("HomeModel: got NWC response, removed %s from the postbox [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString) } else { - print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]") + Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString) } guard resp.response.error == nil else { - print("nwc error: \(resp.response)") + Log.error("HomeModel: NWC wallet raised an error: %s", for: .nwc, String(describing: resp.response)) WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) + if let humanReadableError = resp.response.error?.humanReadableError { + present_sheet(.error(humanReadableError)) + } return } diff --git a/damus/NIP04/NIP04.swift b/damus/NIP04/NIP04.swift @@ -52,4 +52,28 @@ extension NIP04 { return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4) } + + /// Decrypts string content + static func decryptContent(recipientPrivateKey: Privkey, senderPubkey: Pubkey, content: String, encoding: EncEncoding) throws(NIP04DecryptionError) -> String { + guard let shared_sec = get_shared_secret(privkey: recipientPrivateKey, pubkey: senderPubkey) else { + throw .failedToComputeSharedSecret + } + guard let dat = (encoding == .base64 ? decode_dm_base64(content) : decode_dm_bech32(content)) else { + throw .failedToDecodeEncryptedContent + } + guard let dat = aes_decrypt(data: dat.content, iv: dat.iv, shared_sec: shared_sec) else { + throw .failedToDecryptAES + } + guard let decryptedString = String(data: dat, encoding: .utf8) else { + throw .utf8DecodingFailedOnDecryptedPayload + } + return decryptedString + } + + enum NIP04DecryptionError: Error { + case failedToComputeSharedSecret + case failedToDecodeEncryptedContent + case failedToDecryptAES + case utf8DecodingFailedOnDecryptedPayload + } } diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -379,6 +379,10 @@ func decode_json<T: Decodable>(_ val: String) -> T? { return try? JSONDecoder().decode(T.self, from: Data(val.utf8)) } +func decode_json_throwing<T: Decodable>(_ val: String) throws -> T { + return try JSONDecoder().decode(T.self, from: Data(val.utf8)) +} + func decode_data<T: Decodable>(_ data: Data) -> T? { let decoder = JSONDecoder() do { @@ -539,6 +543,7 @@ func event_to_json(ev: NostrEvent) -> String { return str } +@available(*, deprecated, renamed: "NIP04.decryptContent", message: "Deprecated, please use NIP04.decryptContent instead") func decrypt_dm(_ privkey: Privkey?, pubkey: Pubkey, content: String, encoding: EncEncoding) -> String? { guard let privkey = privkey else { return nil diff --git a/damus/Util/WalletConnect/HumanReadableErrors.swift b/damus/Util/WalletConnect/HumanReadableErrors.swift @@ -0,0 +1,97 @@ +// +// HumanReadableErrors.swift +// damus +// +// Created by Daniel D’Aquino on 2025-05-05. +// + +import Foundation + +extension WalletConnect.FullWalletResponse.InitializationError { + var humanReadableError: ErrorView.UserPresentableError? { + switch self { + case .incorrectAuthorPubkey: + nil // Anyone can send a response event with an incorrect author pubkey, it is not really an "error". We should silently ignore it. + case .missingRequestIdReference: + .init( + user_visible_description: NSLocalizedString("Wallet provider returned an invalid response.", comment: "Error description shown to the user when a response from the wallet provider is invalid"), + tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"), + technical_info: "Wallet response does not make a reference to any request; No request ID `e` tag was found." + ) + case .failedToDecodeJSON(let error): + .init( + user_visible_description: NSLocalizedString("Wallet provider returned a response that we do not understand.", comment: "Error description shown to the user when a response from the wallet provider contains data the app does not understand"), + tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"), + technical_info: "Failed to decode NWC Wallet response JSON. Error: \(error)" + ) + case .failedToDecrypt(let error): + .init( + user_visible_description: NSLocalizedString("Wallet provider returned a response that we could not decrypt.", comment: "Error description shown to the user when a response from the wallet provider contains data the app could not decrypt."), + tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"), + technical_info: "Failed to decrypt NWC Wallet response. Error: \(error)" + ) + } + } +} + +extension WalletConnect.WalletResponseErr { + var humanReadableError: ErrorView.UserPresentableError? { + guard let code = self.code else { + return .init( + user_visible_description: String(format: NSLocalizedString("Your connected wallet raised an unknown error. Message: %s", comment: "Human readable error description for unknown error"), self.message ?? NSLocalizedString("Empty error message", comment: "A human readable placeholder to indicate that the error message is empty")), + tip: NSLocalizedString("Please contact the developer of your wallet provider for help.", comment: "Human readable error description for an unknown error raised by a wallet provider."), + technical_info: "NWC wallet provider returned an error response without a valid reason code. Message: \(self.message ?? "Empty error message")" + ) + } + switch code { + case .rateLimited: + return .init( + user_visible_description: NSLocalizedString("Your wallet is temporarily being rate limited.", comment: "Error description for rate limit error"), + tip: NSLocalizedString("Wait a few moments, and then try again.", comment: "Tip for rate limit error"), + technical_info: "Wallet returned a rate limit error with message: \(self.message ?? "No further details provided")" + ) + case .notImplemented: + return .init( + user_visible_description: NSLocalizedString("This feature is not implemented by your wallet.", comment: "Error description for not implemented feature"), + tip: NSLocalizedString("Please check for updates or contact your wallet provider.", comment: "Tip for not implemented error"), + technical_info: "Wallet reported a not implemented error. Message: \(self.message ?? "No further details provided")" + ) + case .insufficientBalance: + return .init( + user_visible_description: NSLocalizedString("Your wallet does not have sufficient balance for this transaction.", comment: "Error description for insufficient balance"), + tip: NSLocalizedString("Please deposit more funds and try again.", comment: "Tip for insufficient balance errors"), + technical_info: "Wallet returned an insufficient balance error. Message: \(self.message ?? "No further details provided")" + ) + case .quotaExceeded: + return .init( + user_visible_description: NSLocalizedString("Your transaction quota has been exceeded.", comment: "Error description for quota exceeded"), + tip: NSLocalizedString("Wait for the quota to reset, or configure your wallet provider to allow a higher limit.", comment: "Tip for quota exceeded"), + technical_info: "Wallet reported a quota exceeded error. Message: \(self.message ?? "No further details provided")" + ) + case .restricted: + return .init( + user_visible_description: NSLocalizedString("This operation is restricted by your wallet.", comment: "Error description for restricted operation"), + tip: NSLocalizedString("Check your account permissions or contact support.", comment: "Tip for restricted operation"), + technical_info: "Wallet returned a restricted error. Message: \(self.message ?? "No further details provided")" + ) + case .unauthorized: + return .init( + user_visible_description: NSLocalizedString("You are not authorized to perform this action with your wallet.", comment: "Error description for unauthorized access"), + tip: NSLocalizedString("Please verify your credentials or permissions.", comment: "Tip for unauthorized access"), + technical_info: "Wallet returned an unauthorized error. Message: \(self.message ?? "No further details provided")" + ) + case .internalError: + return .init( + user_visible_description: NSLocalizedString("An internal error occurred in your wallet.", comment: "Error description for an internal error"), + tip: NSLocalizedString("Try restarting your wallet or contacting support if the problem persists.", comment: "Tip for internal error"), + technical_info: "Wallet reported an internal error. Message: \(self.message ?? "No further details provided")" + ) + case .other: + return .init( + user_visible_description: NSLocalizedString("An unspecified error occurred in your wallet.", comment: "Error description for an unspecified error"), + tip: NSLocalizedString("Please try again or contact your wallet provider for further assistance.", comment: "Tip for unspecified error"), + technical_info: "Wallet returned an error: \(self.message ?? "No further details provided")" + ) + } + } +} diff --git a/damus/Util/WalletConnect/Response.swift b/damus/Util/WalletConnect/Response.swift @@ -5,6 +5,8 @@ // Created by Daniel D’Aquino on 2025-03-10. // +import Combine + extension WalletConnect { /// Models a response from the NWC provider struct Response: Decodable { @@ -50,35 +52,80 @@ extension WalletConnect { let req_id: NoteId let response: Response - init?(from: NostrEvent, nwc: WalletConnect.ConnectURL) async { - guard let note_id = from.referenced_ids.first else { - return nil - } - - self.req_id = note_id + init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) async throws(InitializationError) { + guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey } + + guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference } - let ares = Task { - guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64), - let resp: WalletConnect.Response = decode_json(json) - else { - let resp: WalletConnect.Response? = nil - return resp - } - - return resp + self.req_id = referencedNoteId + + var json = "" + do { + json = try NIP04.decryptContent( + recipientPrivateKey: nwc.keypair.privkey, + senderPubkey: nwc.pubkey, + content: event.content, + encoding: .base64 + ) } + catch { throw .failedToDecrypt(error) } - guard let res = await ares.value else { - return nil + do { + let response: WalletConnect.Response = try decode_json_throwing(json) + self.response = response } - - self.response = res + catch { throw .failedToDecodeJSON(error) } + } + + enum InitializationError: Error { + case incorrectAuthorPubkey + case missingRequestIdReference + case failedToDecodeJSON(any Error) + case failedToDecrypt(any Error) } } struct WalletResponseErr: Codable { - let code: String? + let code: Code? let message: String? + + enum Code: String, Codable { + /// The client is sending commands too fast. It should retry in a few seconds. + case rateLimited = "RATE_LIMITED" + /// The command is not known or is intentionally not implemented. + case notImplemented = "NOT_IMPLEMENTED" + /// The wallet does not have enough funds to cover a fee reserve or the payment amount. + case insufficientBalance = "INSUFFICIENT_BALANCE" + /// The wallet has exceeded its spending quota. + case quotaExceeded = "QUOTA_EXCEEDED" + /// This public key is not allowed to do this operation. + case restricted = "RESTRICTED" + /// This public key has no wallet connected. + case unauthorized = "UNAUTHORIZED" + /// An internal error. + case internalError = "INTERNAL" + /// Other error. + case other = "OTHER" + } + + enum CodingKeys: String, CodingKey { + case code, message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Attempt to decode the code as a String + if let codeString = try container.decodeIfPresent(String.self, forKey: .code), + let validCode = Code(rawValue: codeString) { + self.code = validCode + } else { + // If the code is either missing or not one of the allowed cases, set it to nil + self.code = nil + } + + self.message = try container.decodeIfPresent(String.self, forKey: .message) + } } } diff --git a/damus/Views/ErrorHandling/ErrorView.swift b/damus/Views/ErrorHandling/ErrorView.swift @@ -50,6 +50,10 @@ struct ErrorView: View { .cornerRadius(10) .padding(.vertical, 30) + if let technical_info = error.technical_info { + ErrorTechInfoCopyButton(errorInfo: technical_info) + } + Spacer() if let damus_state, damus_state.is_privkey_user { @@ -69,6 +73,39 @@ struct ErrorView: View { .padding(.top, 20) } + struct ErrorTechInfoCopyButton: View { + let errorInfo: String + @State var copied: Bool = false + + var body: some View { + VStack { + if !copied { + Button(action: { + UIPasteboard.general.string = errorInfo + copied = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + copied = false + } + }, label: { + HStack { + Image(systemName: "square.on.square.dashed") + Text("Copy technical information", comment: "Button label to allow user to copy technical information from an error screen (usually to provide our support team for further troubleshooting)") + } + }) + } + else { + HStack { + Image(systemName: "checkmark.circle") + Text("Copied!", comment: "Label indicating that the error technical information was successfully copied to the clipboard, which shows up as soon as the user clicks the copy button.") + } + .foregroundStyle(.damusGreen) + } + } + .padding(.vertical, 20) + } + } + /// An error that is displayed to the user, and can be sent to the Developers as well. struct UserPresentableError { /// The description of the error to be shown to the user @@ -113,7 +150,7 @@ struct ErrorView: View { error: .init( user_visible_description: "We are still too early", tip: "Stay humble, keep building, stack sats", - technical_info: nil + technical_info: "UTXOs too small, must stack more sats" ) ) }