taler-ios

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

WalletCore.swift (36189B)


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