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