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>
This commit is contained in:
Daniel D’Aquino
2025-05-05 15:53:37 -07:00
parent ab8d52e685
commit fe52381d63
7 changed files with 261 additions and 30 deletions

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -0,0 +1,97 @@
//
// HumanReadableErrors.swift
// damus
//
// Created by Daniel DAquino 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")"
)
}
}
}

View File

@@ -5,6 +5,8 @@
// Created by Daniel DAquino 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
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
}
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) async throws(InitializationError) {
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
guard let res = await ares.value else {
return nil
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
self.req_id = referencedNoteId
var json = ""
do {
json = try NIP04.decryptContent(
recipientPrivateKey: nwc.keypair.privkey,
senderPubkey: nwc.pubkey,
content: event.content,
encoding: .base64
)
}
self.response = res
catch { throw .failedToDecrypt(error) }
do {
let response: WalletConnect.Response = try decode_json_throwing(json)
self.response = response
}
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)
}
}
}

View File

@@ -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"
)
)
}