taler-ios

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

WalletCore.swift (28929B)


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