taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

WalletCore.swift (29171B)


      1 /*
      2  * This file is part of GNU Taler, ©2022-25 Taler Systems S.A.
      3  * See LICENSE.md
      4  */
      5 /**
      6  * @author Marc Stibane
      7  * @author Iván Ávalos
      8  */
      9 import SwiftUI              // FOUNDATION has no AppStorage
     10 import AnyCodable
     11 import SymLog
     12 import os
     13 import LocalConsole
     14 
     15 /// Delegate for the wallet backend.
     16 protocol WalletBackendDelegate {
     17     /// Called when the backend interface receives a message it does not know how to handle.
     18     func walletBackendReceivedUnknownMessage(_ walletCore: WalletCore, message: String)
     19 }
     20 
     21 // MARK: -
     22 /// An interface to the wallet backend.
     23 class WalletCore: QuickjsMessageHandler {
     24     public static let shared = try! WalletCore()          // will (and should) crash on failure
     25     private let symLog = SymLogC()
     26 
     27     private var queue: DispatchQueue
     28     private var semaphore: DispatchSemaphore
     29 
     30     private let quickjs: Quickjs
     31     private var requestsMade: UInt          // counter for array of completion closures
     32     private var completions: [UInt : (Date, (UInt, Date, String?, Data?, WalletBackendResponseError?) -> Void)] = [:]
     33     var delegate: WalletBackendDelegate?
     34 
     35     var versionInfo: VersionInfo?           // shown in SettingsView
     36     var developDelay: Bool?                 // if set in SettingsView will delay wallet-core after each action
     37     var isObserving: Int
     38     var isLogging: Bool
     39     let logger = Logger(subsystem: "net.taler.gnu", category: "WalletCore")
     40 
     41     private var expired: [String] = []      // save txID of expired items to not beep twice
     42 
     43     private struct FullRequest: Encodable {
     44         let operation: String
     45         let id: UInt
     46         let args: AnyEncodable
     47     }
     48 
     49     private struct FullResponse: Decodable {
     50         let type: String
     51         let operation: String
     52         let id: UInt
     53         let result: AnyCodable
     54     }
     55     
     56     struct FullError: Decodable {
     57         let type: String
     58         let operation: String
     59         let id: UInt
     60         let error: WalletBackendResponseError
     61     }
     62 
     63     var lastError: FullError?
     64 
     65     struct ResponseOrNotification: Decodable {
     66         let type: String
     67         let operation: String?
     68         let id: UInt?
     69         let result: AnyCodable?
     70         let error: WalletBackendResponseError?
     71         let payload: AnyCodable?
     72     }
     73 
     74     struct Payload: Decodable {
     75         let type: String
     76         let id: String?
     77         let reservePub: String?
     78         let isInternal: Bool?
     79         let hintTransactionId: String?
     80         let event: [String: AnyCodable]?
     81     }
     82 
     83     deinit {
     84         logger.log("shutdown Quickjs")
     85     // TODO: send shutdown message to talerWalletInstance
     86 //        quickjs.waitStopped()
     87     }
     88 
     89     init() throws {
     90         isObserving = 0
     91         isLogging = false
     92 //        logger.trace("init Quickjs")
     93         requestsMade = 0
     94         queue = DispatchQueue(label: "net.taler.myQueue", attributes: .concurrent)
     95         semaphore = DispatchSemaphore(value: 1)
     96         quickjs = Quickjs()
     97         quickjs.messageHandler = self
     98         logger.log("Quickjs running")
     99     }
    100 }
    101 // MARK: -  completionHandler functions
    102 extension WalletCore {
    103     private func handleError(_ decoded: ResponseOrNotification, _ message: String?) throws {
    104         guard let requestId = decoded.id else {
    105             logger.error("didn't find requestId in error response")
    106             // TODO: show error alert
    107             throw WalletBackendError.deserializationError
    108         }
    109         guard let (timeSent, completion) = completions[requestId] else {
    110             logger.error("requestId \(requestId, privacy: .public) not in list")
    111             // TODO: show error alert
    112             throw WalletBackendError.deserializationError
    113         }
    114         completions[requestId] = nil
    115         if let walletError = decoded.error {            // wallet-core sent an error message
    116             do {
    117                 let jsonData = try JSONEncoder().encode(walletError)
    118                 let responseCode = walletError.errorResponse?.code ?? 0
    119                 logger.error("wallet-core sent back error \(walletError.code, privacy: .public), \(responseCode, privacy: .public) for request \(requestId, privacy: .public)")
    120                 symLog.log("id:\(requestId)  \(walletError)")
    121                 completion(requestId, timeSent, message, jsonData, walletError)
    122             } catch {        // JSON encoding of response.result failed / should never happen
    123                 symLog.log(decoded)
    124                 logger.error("cannot encode wallet-core Error")
    125                 completion(requestId, timeSent, message, nil, WalletCore.parseFailureError())
    126             }
    127         } else {             // JSON decoding of error message failed
    128             completion(requestId, timeSent, message, nil, WalletCore.parseFailureError())
    129         }
    130     }
    131 
    132     private func handleResponse(_ decoded: ResponseOrNotification, _ message: String?) throws {
    133         guard let requestId = decoded.id else {
    134             logger.error("didn't find requestId in response")
    135             symLog.log(decoded)                 // TODO: .error
    136             throw WalletBackendError.deserializationError
    137         }
    138         guard let (timeSent, completion) = completions[requestId] else {
    139             logger.error("requestId \(requestId, privacy: .public) not in list")
    140             throw WalletBackendError.deserializationError
    141         }
    142         completions[requestId] = nil
    143         guard let result = decoded.result else {
    144             logger.error("requestId \(requestId, privacy: .public) got no result")
    145             throw WalletBackendError.deserializationError
    146         }
    147         do {
    148             let jsonData = try JSONEncoder().encode(result)
    149 //            symLog.log("\"id\":\(requestId)  \(result)")
    150 //            logger.info(result)   TODO: log result
    151             completion(requestId, timeSent, message, jsonData, nil)
    152         } catch {        // JSON encoding of response.result failed / should never happen
    153             symLog.log(result)                 // TODO: .error
    154             completion(requestId, timeSent, message, nil, WalletCore.parseResponseError())
    155         }
    156     }
    157 
    158     @MainActor
    159     private func postNotificationM(_ aName: NSNotification.Name,
    160                            object anObject: Any? = nil,
    161                                   userInfo: [AnyHashable: Any]? = nil) async {
    162         NotificationCenter.default.post(name: aName, object: anObject, userInfo: userInfo)
    163     }
    164     private func postNotification(_ aName: NSNotification.Name,
    165                           object anObject: Any? = nil,
    166                                  userInfo: [AnyHashable: Any]? = nil) {
    167         Task { // runs on MainActor
    168             await postNotificationM(aName, object: anObject, userInfo: userInfo)
    169 //            logger.info("Notification sent: \(aName.rawValue, privacy: .public)")
    170         }
    171     }
    172 
    173     private func handlePendingProcessed(_ payload: Payload) throws {
    174         guard let id = payload.id else {
    175             throw WalletBackendError.deserializationError
    176         }
    177         let pendingOp = Notification.Name.PendingOperationProcessed.rawValue
    178         if id.hasPrefix("exchange-update:") {
    179             // Bla Bla Bla
    180         } else if id.hasPrefix("refresh:") {
    181             // Bla Bla Bla
    182         } else if id.hasPrefix("purchase:") {
    183             // TODO: handle purchase
    184 //            symLog.log("\(pendingOp): \(id)")
    185         } else if id.hasPrefix("withdraw:") {
    186             // TODO: handle withdraw
    187 //            symLog.log("\(pendingOp): \(id)")
    188         } else if id.hasPrefix("peer-pull-credit:") {
    189             // TODO: handle peer-pull-credit
    190 //            symLog.log("\(pendingOp): \(id)")
    191         } else if id.hasPrefix("peer-push-debit:") {
    192             // TODO: handle peer-push-debit
    193 //            symLog.log("\(pendingOp): \(id)")
    194         } else {
    195             // TODO: handle other pending-operation-processed
    196             logger.log("❗️ \(pendingOp, privacy: .public): \(id, privacy: .public)")        // this is a new pendingOp I haven't seen before
    197         }
    198     }
    199     @MainActor private func handleStateTransition(_ jsonData: Data) throws {
    200         do {
    201             let decoded = try JSONDecoder().decode(TransactionTransition.self, from: jsonData)
    202             if let errorInfo = decoded.errorInfo {
    203                 // reload pending transaction list to add error badge
    204                 postNotification(.TransactionError, userInfo: [NOTIFICATIONERROR: WalletBackendError.walletCoreError(errorInfo)])
    205             }
    206             guard decoded.newTxState != decoded.oldTxState else {
    207                 logger.info("handleStateTransition: No State change: \(decoded.transactionId, privacy: .private(mask: .hash))")
    208                 return
    209             }
    210 
    211             let components = decoded.transactionId.components(separatedBy: ":")
    212             if components.count >= 3 {  // txn:$txtype:$uid
    213                 if let type = TransactionType(rawValue: components[1]) {
    214                     guard type != .refresh else { return }
    215                     let newMajor = decoded.newTxState.major
    216                     let newMinor = decoded.newTxState.minor
    217                     let oldMinor = decoded.oldTxState?.minor
    218                     switch newMajor {
    219                         case .done:
    220                             logger.info("handleStateTransition: Done: \(decoded.transactionId, privacy: .private(mask: .hash))")
    221                             if type.isWithdrawal {
    222                                 Controller.shared.playSound(2)  // play payment_received only for withdrawals
    223                             } else if !type.isIncoming {
    224                                 if !(oldMinor == .autoRefund || oldMinor == .acceptRefund) {
    225                                     Controller.shared.playSound(1)  // play payment_sent for all outgoing tx
    226                                 }
    227                             } else {    // incoming but not withdrawal
    228                                 logger.info("    incoming payment done - NO sound - \(type.rawValue)")
    229                             }
    230                             postNotification(.TransactionDone, userInfo: [TRANSACTIONTRANSITION: decoded])
    231                             return
    232                         case .aborting:
    233                             if let newMinor {
    234                                 if newMinor == .refreshExpired {
    235                                     logger.warning("handleStateTransition: RefreshExpired: \(decoded.transactionId, privacy: .private(mask: .hash))")
    236                                     if let index = expired.firstIndex(of: components[2]) {
    237                                         expired.remove(at: index)               // don't beep twice
    238                                     } else {
    239                                         expired.append(components[2])
    240                                         Controller.shared.playSound(0)          // beep at first sight
    241                                     }
    242                                     postNotification(.TransactionExpired, userInfo: [TRANSACTIONTRANSITION: decoded])
    243                                     return
    244                                 }
    245                             }
    246                             logger.log("handleStateTransition: Aborting: \(decoded.transactionId, privacy: .private(mask: .hash))")
    247                             postNotification(.TransactionStateTransition, userInfo: [TRANSACTIONTRANSITION: decoded])
    248                         case .expired:
    249                             logger.warning("handleStateTransition: Expired: \(decoded.transactionId, privacy: .private(mask: .hash))")
    250                             if let index = expired.firstIndex(of: components[2]) {
    251                                 expired.remove(at: index)                       // don't beep twice
    252                             } else {
    253                                 expired.append(components[2])
    254                                 Controller.shared.playSound(0)                  // beep at first sight
    255                             }
    256                             postNotification(.TransactionExpired, userInfo: [TRANSACTIONTRANSITION: decoded])
    257                         case .pending:
    258                             if let newMinor {
    259                                 if newMinor == .ready {
    260                                     logger.log("handleStateTransition: PendingReady: \(decoded.transactionId, privacy: .private(mask: .hash))")
    261                                     postNotification(.PendingReady, userInfo: [TRANSACTIONTRANSITION: decoded])
    262                                     return
    263                                 } else if newMinor == .exchangeWaitReserve      // user did confirm on bank website
    264                                        || newMinor == .withdrawCoins {          // coin-withdrawal has started
    265 //                                    logger.log("DismissSheet: \(decoded.transactionId, privacy: .private(mask: .hash))")
    266                                     postNotification(.DismissSheet, userInfo: [TRANSACTIONTRANSITION: decoded])
    267                                     return
    268                                 } else if newMinor == .kyc {       // user did confirm on bank website, but KYC is needed
    269                                     logger.log("handleStateTransition: KYCrequired: \(decoded.transactionId, privacy: .private(mask: .hash))")
    270                                     postNotification(.KYCrequired, userInfo: [TRANSACTIONTRANSITION: decoded])
    271                                     return
    272                                 }
    273                                 logger.trace("handleStateTransition: Pending:\(newMinor.rawValue, privacy: .public) \(decoded.transactionId, privacy: .private(mask: .hash))")
    274                             } else {
    275                                 logger.trace("handleStateTransition: Pending: \(decoded.transactionId, privacy: .private(mask: .hash))")
    276                             }
    277                             postNotification(.TransactionStateTransition, userInfo: [TRANSACTIONTRANSITION: decoded])
    278                         default:
    279                             if let newMinor {
    280                                 logger.log("handleStateTransition: \(newMajor.rawValue, privacy: .public):\(newMinor.rawValue, privacy: .public) \(decoded.transactionId, privacy: .private(mask: .hash))")
    281                             } else {
    282                                 logger.warning("handleStateTransition: \(newMajor.rawValue, privacy: .public): \(decoded.transactionId, privacy: .private(mask: .hash))")
    283                             }
    284                             postNotification(.TransactionStateTransition, userInfo: [TRANSACTIONTRANSITION: decoded])
    285                     } // switch
    286                 } // type
    287             } // 3 components
    288             return
    289         } catch DecodingError.dataCorrupted(let context) {
    290             logger.error("handleStateTransition: \(context.debugDescription)")
    291         } catch DecodingError.keyNotFound(let key, let context) {
    292             logger.error("handleStateTransition: Key '\(key.stringValue)' not found:\(context.debugDescription)")
    293             logger.error("\(context.codingPath)")
    294         } catch DecodingError.valueNotFound(let value, let context) {
    295             logger.error("handleStateTransition: Value '\(value)' not found:\(context.debugDescription)")
    296             logger.error("\(context.codingPath)")
    297         } catch DecodingError.typeMismatch(let type, let context) {
    298             logger.error("handleStateTransition: Type '\(type)' mismatch:\(context.debugDescription)")
    299             logger.error("\(context.codingPath)")
    300         } catch let error {       // rethrows
    301             logger.error("handleStateTransition: \(error.localizedDescription)")
    302         }
    303         throw WalletBackendError.walletCoreError(nil)       // TODO: error?
    304     }
    305 
    306     @MainActor private func handleNotification(_ anyCodable: AnyCodable?, _ message: String) throws {
    307         guard let anyPayload = anyCodable else {
    308             throw WalletBackendError.deserializationError
    309         }
    310         do {
    311             let jsonData = try JSONEncoder().encode(anyPayload)
    312             let payload = try JSONDecoder().decode(Payload.self, from: jsonData)
    313 
    314             switch payload.type {
    315                 case Notification.Name.Idle.rawValue:
    316 //                    symLog.log(message)
    317                     break
    318                 case Notification.Name.ExchangeStateTransition.rawValue:
    319                     symLog.log(message)
    320                     break
    321                 case Notification.Name.TransactionStateTransition.rawValue:
    322                     symLog.log(message)
    323                     try handleStateTransition(jsonData)
    324                 case Notification.Name.PendingOperationProcessed.rawValue:
    325                     try handlePendingProcessed(payload)
    326                 case Notification.Name.BalanceChange.rawValue:
    327                     let now = Date()
    328                     symLog.log(message)
    329                     if !(payload.isInternal ?? false) {     // don't re-post internals
    330                         if let txID = payload.hintTransactionId {
    331                             if txID.contains("txn:refresh:") {
    332                                 break                       // don't re-post refresh
    333                             }
    334                         }
    335                         postNotification(.BalanceChange, userInfo: [NOTIFICATIONTIME: now])
    336                     }
    337                 case Notification.Name.BankAccountChange.rawValue:
    338                     symLog.log(message)
    339                     postNotification(.BankAccountChange)
    340                 case Notification.Name.ExchangeAdded.rawValue:
    341                     symLog.log(message)
    342                     postNotification(.ExchangeAdded)
    343                 case Notification.Name.ExchangeDeleted.rawValue:
    344                     symLog.log(message)
    345                     postNotification(.ExchangeDeleted)
    346                 case Notification.Name.ReserveNotYetFound.rawValue:
    347                     if let reservePub = payload.reservePub {
    348                         let userInfo = ["reservePub" : reservePub]
    349 //                        postNotification(.ReserveNotYetFound, userInfo: userInfo)   // TODO: remind User to confirm withdrawal
    350                     } // else { throw WalletBackendError.deserializationError }   shouldn't happen, but if it does just ignore it
    351 
    352                 case Notification.Name.ProposalAccepted.rawValue:               // "proposal-accepted":
    353                     symLog.log(message)
    354                     postNotification(.ProposalAccepted, userInfo: nil)
    355                 case Notification.Name.ProposalDownloaded.rawValue:             // "proposal-downloaded":
    356                     symLog.log(message)
    357                     postNotification(.ProposalDownloaded, userInfo: nil)
    358                 case Notification.Name.TaskObservabilityEvent.rawValue,
    359                      Notification.Name.RequestObservabilityEvent.rawValue:
    360                     if isObserving != 0 {
    361                         symLog.log(message)
    362                         let timestamp = TalerDater.dateString()
    363                         if let event = payload.event, let json = event.toJSON() {
    364                             let type = event["type"]?.value as? String
    365                             let eventID = event["id"]?.value as? String
    366                             observe(json: json,
    367                                     type: type,
    368                                     eventID: eventID,
    369                                     timestamp: timestamp)
    370                         }
    371                     }
    372                     // TODO: remove these once wallet-core doesn't send them anymore
    373 //                case "refresh-started", "refresh-melted",
    374 //                     "refresh-revealed", "refresh-unwarranted":
    375 //                    break
    376                 default:
    377                     logger.error("NEW Notification: \(message)")        // this is a new notification I haven't seen before
    378                     break
    379             }
    380         } catch let error {
    381             logger.error("Error \(error) parsing notification: \(message)")
    382             postNotification(.GeneralError, userInfo: [NOTIFICATIONERROR: error])
    383         // TODO: if DevMode then should log into file for user
    384         }
    385     }
    386 
    387     @MainActor func handleLog(message: String) {
    388         if isLogging {
    389             let consoleManager = LCManager.shared
    390             consoleManager.print(message)
    391         }
    392     }
    393 
    394     @MainActor func observe(json: String, type: String?, eventID: String?, timestamp: String) {
    395         let consoleManager = LCManager.shared
    396         if let type {
    397             if let eventID {
    398                 consoleManager.print("\(type)   \(eventID)")
    399             } else {
    400                 consoleManager.print(type)
    401             }
    402         }
    403         consoleManager.print("   \(timestamp)")
    404         if isObserving < 0 {
    405             consoleManager.print(json)
    406         }
    407         consoleManager.print("-   -   -")
    408     }
    409 
    410     /// here not only responses, but also notifications from wallet-core will be received
    411     @MainActor func handleMessage(message: String) {
    412         do {
    413             var asyncDelay = 0
    414             if let delay: Bool = developDelay {   // Settings: 2 seconds delay
    415                 if delay {
    416                     asyncDelay = 2
    417                 }
    418             }
    419             if asyncDelay > 0 {
    420                 symLog.log(message)
    421                 symLog.log("...going to sleep for \(asyncDelay) seconds...")
    422                 sleep(UInt32(asyncDelay))
    423                 symLog.log("waking up again after \(asyncDelay) seconds, will deliver message")
    424             }
    425             guard let messageData = message.data(using: .utf8) else {
    426                 throw WalletBackendError.deserializationError
    427             }
    428             let decoded = try JSONDecoder().decode(ResponseOrNotification.self, from: messageData)
    429             switch decoded.type {
    430                 case "error":
    431                     symLog.log("\"id\":\(decoded.id ?? 0)  \(message)")
    432                     try handleError(decoded, message)
    433                 case "response":
    434                     symLog.log(message)
    435                     try handleResponse(decoded, message)
    436                 case "notification":
    437 //                    symLog.log(message)
    438                     try handleNotification(decoded.payload, message)
    439                 case "tunnelHttp":          // TODO: Handle tunnelHttp
    440                     symLog.log("Can't handle tunnelHttp: \(message)")    // TODO: .error
    441                     throw WalletBackendError.deserializationError
    442                 default:
    443                     symLog.log("Unknown response type: \(message)")    // TODO: .error
    444                     throw WalletBackendError.deserializationError
    445             }
    446         } catch DecodingError.dataCorrupted(let context) {
    447             logger.error("\(context.debugDescription)")
    448         } catch DecodingError.keyNotFound(let key, let context) {
    449             logger.error("Key '\(key.stringValue)' not found:\(context.debugDescription)")
    450             logger.error("\(context.codingPath)")
    451         } catch DecodingError.valueNotFound(let value, let context) {
    452             logger.error("Value '\(value)' not found:\(context.debugDescription)")
    453             logger.error("\(context.codingPath)")
    454         } catch DecodingError.typeMismatch(let type, let context) {
    455             logger.error("Type '\(type)' mismatch:\(context.debugDescription)")
    456             logger.error("\(context.codingPath)")
    457         } catch let error {
    458             logger.error("\(error.localizedDescription)")
    459         } catch { // TODO: ?
    460             delegate?.walletBackendReceivedUnknownMessage(self, message: message)
    461         }
    462     }
    463     
    464     private func encodeAndSend(_ request: WalletBackendRequest, completionHandler: @escaping (UInt, Date, String?, Data?, WalletBackendResponseError?) -> Void) {
    465         // Encode the request and send it to the backend.
    466         queue.async {
    467             self.semaphore.wait()               // guard access to requestsMade
    468             let requestId = self.requestsMade
    469             let sendTime = Date.now
    470             do {
    471                 let full = FullRequest(operation: request.operation, id: requestId, args: request.args)
    472 //          symLog.log(full)
    473                 let encoded = try JSONEncoder().encode(full)
    474                 guard let jsonString = String(data: encoded, encoding: .utf8) else { throw WalletBackendError.serializationError }
    475                 self.completions[requestId] = (sendTime, completionHandler)
    476                 self.requestsMade += 1
    477                 self.semaphore.signal()         // free requestsMade
    478                     let args = try JSONEncoder().encode(request.args)
    479                     if let jsonArgs = String(data: args, encoding: .utf8) {
    480                         self.logger.log("🔴\"id\":\(requestId, privacy: .public) \(request.operation, privacy: .public)\(jsonArgs, privacy: .auto)")
    481                     } else {    // should NEVER happen since the whole request was already successfully encoded and stringified
    482                         self.logger.log("🔴\"id\":\(requestId, privacy: .public) \(request.operation, privacy: .public) 🔴 Error: jsonArgs")
    483                     }
    484                 self.quickjs.sendMessage(message: jsonString)
    485 //              self.symLog.log(jsonString)
    486             } catch {       // call completion
    487                 self.semaphore.signal()         // free requestsMade
    488                 self.logger.error("\(error.localizedDescription)")
    489 //              self.symLog.log(error)
    490                 completionHandler(requestId, sendTime, nil, nil, WalletCore.serializeRequestError());
    491             }
    492         }
    493     }
    494 }
    495 // MARK: -  async / await function
    496 extension WalletCore {
    497     /// send async requests to wallet-core
    498     func sendFormattedRequest<T: WalletBackendFormattedRequest> (_ request: T, asJSON: Bool = false) async throws -> (T.Response, UInt) {
    499         let reqData = WalletBackendRequest(operation: request.operation(),
    500                                            args: AnyEncodable(request.args()))
    501         return try await withCheckedThrowingContinuation { continuation in
    502             encodeAndSend(reqData) { [self] requestId, timeSent, message, result, error in
    503                 let timeUsed = Date.now - timeSent
    504                 let millisecs = timeUsed.milliseconds
    505                 if let error {
    506                     logger.error("Request \"id\":\(requestId, privacy: .public) failed after \(millisecs, privacy: .public) ms")
    507                 } else {
    508                     if millisecs > 50 {
    509                         logger.info("Request \"id\":\(requestId, privacy: .public) took \(millisecs, privacy: .public) ms")
    510                     }
    511                 }
    512                 var err: Error? = nil
    513                 if let json = result, error == nil {
    514                     do {
    515                         if asJSON {
    516                             if let message {
    517                                 continuation.resume(returning: (message as! T.Response, requestId))
    518                             } else {
    519                                 continuation.resume(throwing: TransactionDecodingError.invalidStringValue)
    520                             }
    521                         } else {
    522                             let decoded = try JSONDecoder().decode(T.Response.self, from: json)
    523                             continuation.resume(returning: (decoded, requestId))
    524                         }
    525                         return
    526                     } catch DecodingError.dataCorrupted(let context) {
    527                         logger.error("\(context.debugDescription)")
    528                     } catch DecodingError.keyNotFound(let key, let context) {
    529                         logger.error("Key '\(key.stringValue)' not found:\(context.debugDescription)")
    530                         logger.error("\(context.codingPath)")
    531                     } catch DecodingError.valueNotFound(let value, let context) {
    532                         logger.error("Value '\(value)' not found:\(context.debugDescription)")
    533                         logger.error("\(context.codingPath)")
    534                     } catch DecodingError.typeMismatch(let type, let context) {
    535                         logger.error("Type '\(type)' mismatch:\(context.debugDescription)")
    536                         logger.error("\(context.codingPath)")
    537                     } catch {       // rethrows
    538                         if let jsonString = String(data: json, encoding: .utf8) {
    539                             symLog.log(jsonString)       // TODO: .error
    540                         } else {
    541                             symLog.log(json)       // TODO: .error
    542                         }
    543                         err = error     // this will be thrown in continuation.resume(throwing:), otherwise keep nil
    544                     }
    545                 } else if let error {
    546                     // TODO: WALLET_CORE_REQUEST_CANCELLED
    547                     lastError = FullError(type: "error", operation: request.operation(), id: requestId, error: error)
    548                     err = WalletBackendError.walletCoreError(error)
    549                 } else {        // both result and error are nil
    550                     lastError = nil
    551                 }
    552                 continuation.resume(throwing: err ?? TransactionDecodingError.invalidStringValue)
    553             }
    554         }
    555     }
    556 }