WalletCore.swift (29116B)
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 if #available(iOS 16.0, *) { 364 observe(json: json, 365 type: type, 366 eventID: eventID, 367 timestamp: timestamp) 368 } 369 } 370 } 371 // TODO: remove these once wallet-core doesn't send them anymore 372 // case "refresh-started", "refresh-melted", 373 // "refresh-revealed", "refresh-unwarranted": 374 // break 375 default: 376 logger.error("NEW Notification: \(message)") // this is a new notification I haven't seen before 377 break 378 } 379 } catch let error { 380 logger.error("Error \(error) parsing notification: \(message)") 381 postNotification(.GeneralError, userInfo: [NOTIFICATIONERROR: error]) 382 // TODO: if DevMode then should log into file for user 383 } 384 } 385 386 @MainActor func handleLog(message: String) { 387 if #available (iOS 16.0, *) { 388 if isLogging { 389 let consoleManager = LCManager.shared 390 consoleManager.print(message) 391 } 392 } 393 } 394 395 @available(iOS 16.0, *) 396 @MainActor func observe(json: String, type: String?, eventID: String?, timestamp: String) { 397 let consoleManager = LCManager.shared 398 if let type { 399 if let eventID { 400 consoleManager.print("\(type) \(eventID)") 401 } else { 402 consoleManager.print(type) 403 } 404 } 405 consoleManager.print(" \(timestamp)") 406 if isObserving < 0 { 407 consoleManager.print(json) 408 } 409 consoleManager.print("- - -") 410 } 411 412 /// here not only responses, but also notifications from wallet-core will be received 413 @MainActor func handleMessage(message: String) { 414 do { 415 var asyncDelay = 0 416 if let delay: Bool = developDelay { // Settings: 2 seconds delay 417 if delay { 418 asyncDelay = 2 419 } 420 } 421 if asyncDelay > 0 { 422 symLog.log(message) 423 symLog.log("...going to sleep for \(asyncDelay) seconds...") 424 sleep(UInt32(asyncDelay)) 425 symLog.log("waking up again after \(asyncDelay) seconds, will deliver message") 426 } 427 guard let messageData = message.data(using: .utf8) else { 428 throw WalletBackendError.deserializationError 429 } 430 let decoded = try JSONDecoder().decode(ResponseOrNotification.self, from: messageData) 431 switch decoded.type { 432 case "error": 433 symLog.log("\"id\":\(decoded.id ?? 0) \(message)") 434 try handleError(decoded, message) 435 case "response": 436 // symLog.log(message) 437 try handleResponse(decoded, message) 438 case "notification": 439 // symLog.log(message) 440 try handleNotification(decoded.payload, message) 441 case "tunnelHttp": // TODO: Handle tunnelHttp 442 symLog.log("Can't handle tunnelHttp: \(message)") // TODO: .error 443 throw WalletBackendError.deserializationError 444 default: 445 symLog.log("Unknown response type: \(message)") // TODO: .error 446 throw WalletBackendError.deserializationError 447 } 448 } catch DecodingError.dataCorrupted(let context) { 449 logger.error("\(context.debugDescription)") 450 } catch DecodingError.keyNotFound(let key, let context) { 451 logger.error("Key '\(key.stringValue)' not found:\(context.debugDescription)") 452 logger.error("\(context.codingPath)") 453 } catch DecodingError.valueNotFound(let value, let context) { 454 logger.error("Value '\(value)' not found:\(context.debugDescription)") 455 logger.error("\(context.codingPath)") 456 } catch DecodingError.typeMismatch(let type, let context) { 457 logger.error("Type '\(type)' mismatch:\(context.debugDescription)") 458 logger.error("\(context.codingPath)") 459 } catch let error { 460 logger.error("\(error.localizedDescription)") 461 } catch { // TODO: ? 462 delegate?.walletBackendReceivedUnknownMessage(self, message: message) 463 } 464 } 465 466 private func encodeAndSend(_ request: WalletBackendRequest, completionHandler: @escaping (UInt, Date, String?, Data?, WalletBackendResponseError?) -> Void) { 467 // Encode the request and send it to the backend. 468 queue.async { 469 self.semaphore.wait() // guard access to requestsMade 470 let requestId = self.requestsMade 471 let sendTime = Date.now 472 do { 473 let full = FullRequest(operation: request.operation, id: requestId, args: request.args) 474 // symLog.log(full) 475 let encoded = try JSONEncoder().encode(full) 476 guard let jsonString = String(data: encoded, encoding: .utf8) else { throw WalletBackendError.serializationError } 477 self.completions[requestId] = (sendTime, completionHandler) 478 self.requestsMade += 1 479 self.semaphore.signal() // free requestsMade 480 let args = try JSONEncoder().encode(request.args) 481 if let jsonArgs = String(data: args, encoding: .utf8) { 482 if request.operation == "getTransactionsV2" { 483 if self.logTransactions { 484 self.logger.trace("🔴\"id\":\(requestId, privacy: .public) \(request.operation, privacy: .public)\(jsonArgs, privacy: .auto)") 485 } 486 } else { 487 self.logger.log("🔴\"id\":\(requestId, privacy: .public) \(request.operation, privacy: .public)\(jsonArgs, privacy: .auto)") 488 } 489 } else { // should NEVER happen since the whole request was already successfully encoded and stringified 490 self.logger.log("🔴\"id\":\(requestId, privacy: .public) \(request.operation, privacy: .public) 🔴 Error: jsonArgs") 491 } 492 self.quickjs.sendMessage(message: jsonString) 493 // self.symLog.log(jsonString) 494 } catch { // call completion 495 self.semaphore.signal() // free requestsMade 496 self.logger.error("\(error.localizedDescription)") 497 // self.symLog.log(error) 498 completionHandler(requestId, sendTime, nil, nil, WalletCore.serializeRequestError()); 499 } 500 } 501 } 502 } 503 // MARK: - async / await function 504 extension WalletCore { 505 /// send async requests to wallet-core 506 func sendFormattedRequest<T: WalletBackendFormattedRequest> (_ request: T, asJSON: Bool = false) async throws -> (T.Response, UInt) { 507 let reqData = WalletBackendRequest(operation: request.operation(), 508 args: AnyEncodable(request.args())) 509 return try await withCheckedThrowingContinuation { continuation in 510 encodeAndSend(reqData) { [self] requestId, timeSent, message, result, error in 511 let timeUsed = Date.now - timeSent 512 let millisecs = timeUsed.milliseconds 513 if let error { 514 logger.error("Request \"id\":\(requestId, privacy: .public) failed after \(millisecs, privacy: .public) ms") 515 } else { 516 if millisecs > 50 { 517 logger.info("Request \"id\":\(requestId, privacy: .public) took \(millisecs, privacy: .public) ms") 518 } 519 } 520 var err: Error? = nil 521 if let json = result, error == nil { 522 do { 523 if asJSON { 524 if let message { 525 continuation.resume(returning: (message as! T.Response, requestId)) 526 } else { 527 continuation.resume(throwing: TransactionDecodingError.invalidStringValue) 528 } 529 } else { 530 let decoded = try JSONDecoder().decode(T.Response.self, from: json) 531 continuation.resume(returning: (decoded, requestId)) 532 } 533 return 534 } catch DecodingError.dataCorrupted(let context) { 535 logger.error("\(context.debugDescription)") 536 } catch DecodingError.keyNotFound(let key, let context) { 537 logger.error("Key '\(key.stringValue)' not found:\(context.debugDescription)") 538 logger.error("\(context.codingPath)") 539 } catch DecodingError.valueNotFound(let value, let context) { 540 logger.error("Value '\(value)' not found:\(context.debugDescription)") 541 logger.error("\(context.codingPath)") 542 } catch DecodingError.typeMismatch(let type, let context) { 543 logger.error("Type '\(type)' mismatch:\(context.debugDescription)") 544 logger.error("\(context.codingPath)") 545 } catch { // rethrows 546 if let jsonString = String(data: json, encoding: .utf8) { 547 symLog.log(jsonString) // TODO: .error 548 } else { 549 symLog.log(json) // TODO: .error 550 } 551 err = error // this will be thrown in continuation.resume(throwing:), otherwise keep nil 552 } 553 } else if let error { 554 // TODO: WALLET_CORE_REQUEST_CANCELLED 555 lastError = FullError(type: "error", operation: request.operation(), id: requestId, error: error) 556 err = WalletBackendError.walletCoreError(error) 557 } else { // both result and error are nil 558 lastError = nil 559 } 560 continuation.resume(throwing: err ?? TransactionDecodingError.invalidStringValue) 561 } 562 } 563 } 564 }