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