taler-ios

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

WalletCore.swift (29742B)


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