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