taler-ios

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

WalletCore.swift (33003B)


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