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 }