WalletModel.swift (26917B)
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 */ 8 import Foundation 9 import taler_swift 10 import SymLog 11 import os.log 12 13 enum InsufficientBalanceHint: String, Codable { 14 /// Merchant doesn't accept money from exchange(s) that the wallet supports 15 case merchantAcceptInsufficient = "merchant-accept-insufficient" 16 /// Merchant accepts funds from a matching exchange, but the funds can't be deposited with the wire method 17 case merchantDepositInsufficient = "merchant-deposit-insufficient" 18 /// While in principle the balance is sufficient, the age restriction on coins causes the spendable balance to be insufficient 19 case ageRestricted = "age-restricted" 20 /// Wallet has enough available funds, but the material funds are insufficient 21 /// Usually because there is a pending refresh operation 22 case walletBalanceMaterialInsufficient = "wallet-balance-material-insufficient" 23 /// The wallet simply doesn't have enough available funds 24 case walletBalanceAvailableInsufficient = "wallet-balance-available-insufficient" 25 /// Exchange is missing the global fee configuration, thus fees are unknown 26 /// and funds from this exchange can't be used for p2p payments 27 case exchangeMissingGlobalFees = "exchange-missing-global-fees" 28 /// Even though the balance looks sufficient for the instructed amount, 29 /// the fees can be covered by neither the merchant nor the remaining wallet balance 30 case feesNotCovered = "fees-not-covered" 31 /// wallet-core keeps adding hints (feesNotCovered and exchangeMissingGlobalFees were 32 /// added after this enum was written). Throwing on the next one would take the whole 33 /// error message down with it - and an error that fails to decode never resumes the 34 /// Task waiting for that request. 35 case unknown 36 37 init(from decoder: Decoder) throws { 38 let raw = try decoder.singleValueContainer().decode(String.self) 39 self = InsufficientBalanceHint(rawValue: raw) ?? .unknown 40 } 41 42 func localizedCause(_ currency: String) -> String { 43 switch self { 44 case .merchantAcceptInsufficient: 45 String(localized: "payment_balance_insufficient_hint_merchant_accept_insufficient", 46 defaultValue: "Merchant doesn't accept money from one or more providers in this wallet") 47 case .merchantDepositInsufficient: 48 String(localized: "payment_balance_insufficient_hint_merchant_deposit_insufficient", 49 defaultValue: "Merchant doesn't accept the wire method of the provider, this likely means it is misconfigured") 50 case .ageRestricted: 51 String(localized: "payment_balance_insufficient_hint_age_restricted", 52 defaultValue: "Purchase not possible due to age restriction") 53 case .walletBalanceMaterialInsufficient: 54 String(localized: "payment_balance_insufficient_hint_wallet_balance_material_insufficient", 55 defaultValue: "Some of the digital cash needed for this purchase is currently unavailable") 56 case .walletBalanceAvailableInsufficient: 57 String(localized: "payment_balance_insufficient_max", 58 defaultValue: "Balance insufficient! You don't have enough \(currency).") 59 case .exchangeMissingGlobalFees: 60 String(localized: "payment_balance_insufficient_hint_exchange_missing_global_fees", 61 defaultValue: "Provider is missing the global fee configuration, this likely means it is misconfigured") 62 case .feesNotCovered: 63 String(localized: "payment_balance_insufficient_hint_fees_not_covered", 64 defaultValue: "Not enough funds to pay the provider fees not covered by the merchant") 65 case .unknown: // we don't know the reason, but we do know the balance is insufficient 66 String(localized: "payment_balance_insufficient_max", 67 defaultValue: "Balance insufficient! You don't have enough \(currency).") 68 } 69 } 70 } 71 72 struct InsufficientBalanceDetailsPerExchange: Codable, Hashable { 73 var balanceAvailable: Amount 74 var balanceMaterial: Amount 75 var balanceExchangeDepositable: Amount 76 var balanceAgeAcceptable: Amount 77 var balanceReceiverAcceptable: Amount? // deprecated 78 var balanceReceiverDepositable: Amount 79 var maxEffectiveSpendAmount: Amount 80 /// Exchange doesn't have global fees configured for the relevant year, p2p payments aren't possible. 81 var missingGlobalFees: Bool? // deprecated 82 } 83 84 /// Detailed reason for why the wallet's balance is insufficient. 85 struct PaymentInsufficientBalanceDetails: Codable, Hashable { 86 /// Amount requested by the merchant. 87 var amountRequested: Amount 88 var causeHint: InsufficientBalanceHint? 89 /// Balance of type "available" (see balance.ts for definition). 90 var balanceAvailable: Amount 91 /// Balance of type "material" (see balance.ts for definition). 92 var balanceMaterial: Amount 93 /// Balance of type "age-acceptable" (see balance.ts for definition). 94 var balanceAgeAcceptable: Amount 95 /// Balance of type "merchant-acceptable" (see balance.ts for definition). 96 var balanceReceiverAcceptable: Amount 97 /// Balance of type ... 98 var balanceReceiverDepositable: Amount 99 var balanceExchangeDepositable: Amount 100 /// Maximum effective amount that the wallet can spend, when all fees are paid by the wallet. 101 var maxEffectiveSpendAmount: Amount 102 var perExchange: [String : InsufficientBalanceDetailsPerExchange] 103 } 104 // MARK: - 105 struct TalerErrorInfo: Codable, Hashable { 106 /// Numeric error code defined in the GANA gnu-taler-error-codes registry. 107 /// Optional: this is the response of some *other* server, which need not 108 /// be a Taler error object at all. 109 var code: Int? 110 // all other fields are optional: 111 var when: Timestamp? 112 /// English description of the error code. 113 var hint: String? 114 var message: String? 115 } 116 struct TalerErrorDetail: Codable, Hashable { 117 /// Numeric error code defined in the GANA gnu-taler-error-codes registry. 118 var code: Int 119 // all other fields are optional: 120 var when: Timestamp? 121 /// English description of the error code. 122 var hint: String? 123 124 /// Error details, type depends on `talerErrorCode`. 125 var detail: String? 126 127 /// HTTPError 128 var requestUrl: String? 129 var requestMethod: String? 130 var httpStatusCode: Int? 131 var stack: String? 132 var errorResponse: TalerErrorInfo? 133 134 var insufficientBalanceDetails: PaymentInsufficientBalanceDetails? 135 136 enum CodingKeys: String, CodingKey { 137 case code, when, hint, detail 138 case requestUrl, requestMethod, httpStatusCode, stack 139 case errorResponse, insufficientBalanceDetails 140 } 141 } 142 extension TalerErrorDetail { 143 /// In wallet-core a TalerErrorDetail is an open dictionary: besides code, when and 144 /// hint it carries whatever the throwing code put there, and the shape of that 145 /// differs per error code - `detail`, for instance, is a string for some codes and a 146 /// nested error object for others. 147 /// Throwing here is not survivable: the error arrives inside the top-level message 148 /// envelope, so if it doesn't decode then WalletCore never finds the request id and 149 /// the continuation awaiting that request is never resumed - the sheet spins forever. 150 /// Therefore decode the optional extras defensively and drop what we cannot read. 151 init(from decoder: Decoder) throws { 152 let container = try decoder.container(keyedBy: CodingKeys.self) 153 code = try container.decode(Int.self, forKey: .code) 154 when = try? container.decodeIfPresent(Timestamp.self, forKey: .when) 155 hint = try? container.decodeIfPresent(String.self, forKey: .hint) 156 detail = try? container.decodeIfPresent(String.self, forKey: .detail) 157 requestUrl = try? container.decodeIfPresent(String.self, forKey: .requestUrl) 158 requestMethod = try? container.decodeIfPresent(String.self, forKey: .requestMethod) 159 httpStatusCode = try? container.decodeIfPresent(Int.self, forKey: .httpStatusCode) 160 stack = try? container.decodeIfPresent(String.self, forKey: .stack) 161 errorResponse = try? container.decodeIfPresent(TalerErrorInfo.self, forKey: .errorResponse) 162 insufficientBalanceDetails = try? container.decodeIfPresent(PaymentInsufficientBalanceDetails.self, 163 forKey: .insufficientBalanceDetails) 164 } 165 } 166 // MARK: - 167 /// Communicate with wallet-core 168 final class WalletModel: ObservableObject, Sendable { 169 public static let shared = WalletModel() 170 let logger = Logger(subsystem: "net.taler.gnu", category: "WalletModel") 171 172 @Published var error2: ErrorData? = nil 173 174 @MainActor func setError(_ theError: Error?) { 175 if let theError { 176 self.error2 = .error(theError) 177 } else { 178 self.error2 = nil 179 } 180 } 181 @MainActor func setMessage(_ title: String,_ theMessage: String?) { 182 if let theMessage { 183 self.error2 = .message(title: title, message: theMessage) 184 } else { 185 self.error2 = nil 186 } 187 } 188 189 func sendRequest<T: WalletBackendFormattedRequest> (_ request: T, viewHandles: Bool = false, asJSON: Bool = false) 190 async throws -> T.Response { // T for any Thread 191 #if !DEBUG 192 logger.log("sending: \(request.operation, privacy: .public)") 193 #endif 194 let sendTime = Date.now 195 do { 196 let (response, id) = try await WalletCore.shared.sendFormattedRequest(request, asJSON: asJSON) 197 #if !DEBUG 198 let timeUsed = Date.now - sendTime 199 logger.log("received: \(request.operation, privacy: .public) (\(id, privacy: .public)) after \(timeUsed.milliseconds, privacy: .public) ms") 200 #endif 201 return response 202 } catch { // rethrows 203 let timeUsed = Date.now - sendTime 204 logger.error("\(request.operation, privacy: .public) failed after \(timeUsed.milliseconds, privacy: .public) ms\n\(error, privacy: .public)") 205 if !viewHandles { 206 // TODO: symlog + controller sound 207 await setError(error) 208 } 209 throw error 210 } 211 } 212 } 213 // MARK: - 214 struct DbStatus: Decodable, Sendable { 215 var dbReadHealthy: Bool 216 var dbWriteHealthy: Bool 217 } 218 /// A request to tell wallet-core about the network. 219 fileprivate struct ApplicationResumedRequest: WalletBackendFormattedRequest { 220 typealias Response = DbStatus 221 var operation: String { "hintApplicationResumed" } 222 func args() -> Args { Args() } 223 224 struct Args: Encodable {} // no arguments needed 225 } 226 227 fileprivate struct NetworkAvailabilityRequest: WalletBackendFormattedRequest { 228 struct Response: Decodable {} 229 var operation: String { "hintNetworkAvailability" } 230 func args() -> Args { Args(isNetworkAvailable: isNetworkAvailable) } 231 232 var isNetworkAvailable: Bool 233 234 struct Args: Encodable { 235 var isNetworkAvailable: Bool 236 } 237 } 238 239 extension WalletModel { 240 func hintNetworkAvailabilityT(_ isNetworkAvailable: Bool = false) async { 241 // T for any Thread 242 let request = NetworkAvailabilityRequest(isNetworkAvailable: isNetworkAvailable) 243 _ = try? await sendRequest(request) 244 } 245 func hintApplicationResumedT() async throws -> DbStatus { 246 // T for any Thread 247 let request = ApplicationResumedRequest() 248 return try await sendRequest(request) 249 } 250 } 251 // MARK: - 252 /// A request to cancel a wallet transaction by token. 253 fileprivate struct CancelProgressToken: WalletBackendFormattedRequest { 254 struct Response: Decodable {} 255 var operation: String { "cancelProgressToken" } 256 func args() -> Args { Args(operation: op, progressToken: token) } 257 258 var op: String 259 var token: String 260 261 struct Args: Encodable { 262 var operation: String 263 var progressToken: String 264 } 265 } 266 /// A request to retry a wallet transaction by token. 267 fileprivate struct RetryProgressTokenNow: WalletBackendFormattedRequest { 268 struct Response: Decodable {} 269 var operation: String { "retryProgressTokenNow" } 270 func args() -> Args { Args(operation: op, progressToken: token) } 271 272 var op: String 273 var token: String 274 275 struct Args: Encodable { 276 var operation: String 277 var progressToken: String 278 } 279 } 280 // MARK: - 281 /// A request to get a wallet transaction by ID. 282 fileprivate struct GetTransactionById: WalletBackendFormattedRequest { 283 typealias Response = TalerTransaction 284 var operation: String { "getTransactionById" } 285 func args() -> Args { Args(transactionId: txId, includeContractTerms: inclTerms) } 286 287 var txId: String 288 var inclTerms: Bool? 289 290 struct Args: Encodable { 291 var transactionId: String 292 var includeContractTerms: Bool? 293 } 294 } 295 296 fileprivate struct JSONTransactionById: WalletBackendFormattedRequest { 297 typealias Response = String 298 var operation: String { "getTransactionById" } 299 func args() -> Args { Args(transactionId: transactionId, includeContractTerms: includeContractTerms) } 300 301 var transactionId: String 302 var includeContractTerms: Bool? 303 304 struct Args: Encodable { 305 var transactionId: String 306 var includeContractTerms: Bool? 307 } 308 } 309 310 extension WalletModel { 311 nonisolated func cancelProgressToken(_ op: String, token: String) 312 async throws { 313 let request = CancelProgressToken(op: op, token: token) 314 let _ = try await sendRequest(request) 315 } 316 nonisolated func retryProgressTokenNow(_ op: String, token: String) 317 async throws { 318 let request = RetryProgressTokenNow(op: op, token: token) 319 let _ = try await sendRequest(request) 320 } 321 /// get the specified transaction from Wallet-Core. No networking involved 322 nonisolated func getTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false) 323 async throws -> TalerTransaction { 324 let request = GetTransactionById(txId: transactionId, inclTerms: includeContractTerms) 325 return try await sendRequest(request, viewHandles: viewHandles) 326 } 327 nonisolated func jsonTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false) 328 async throws -> String { 329 let request = JSONTransactionById(transactionId: transactionId, includeContractTerms: includeContractTerms) 330 return try await sendRequest(request, viewHandles: viewHandles, asJSON: true) 331 } 332 } 333 // MARK: - 334 /// The info returned from Wallet-core init 335 struct VersionInfo: Decodable { 336 var implementationSemver: String? 337 var implementationGitHash: String? 338 var version: String 339 var exchange: String 340 var merchant: String 341 var bank: String 342 } 343 // MARK: - 344 fileprivate struct Testing: Encodable { 345 var denomselAllowLate: Bool 346 var devModeActive: Bool 347 var insecureTrustExchange: Bool 348 var preventThrottling: Bool 349 var skipDefaults: Bool 350 var emitObservabilityEvents: Bool 351 // more to come... 352 353 init(devModeActive: Bool) { 354 self.denomselAllowLate = false 355 self.devModeActive = devModeActive 356 self.insecureTrustExchange = false 357 self.preventThrottling = false 358 self.skipDefaults = false 359 self.emitObservabilityEvents = devModeActive 360 } 361 } 362 363 fileprivate struct Builtin: Encodable { 364 var exchanges: [String] 365 // more to come... 366 } 367 368 fileprivate struct Features: Encodable { 369 var migrateNativeDb: Bool // obsolete - use migrateDatabase() instead 370 var useNativeDb: Bool 371 } 372 373 fileprivate struct Config: Encodable { 374 var testing: Testing 375 var builtin: Builtin 376 var features: Features 377 } 378 // MARK: - 379 /// A request to re-configure Wallet-core 380 fileprivate struct ConfigRequest: WalletBackendFormattedRequest { 381 var setTesting: Bool 382 383 var operation: String { "setWalletRunConfig" } 384 func args() -> Args { 385 let testing = Testing(devModeActive: setTesting) 386 let builtin = Builtin(exchanges: []) 387 let features = Features(migrateNativeDb: false, useNativeDb: true) 388 let config = Config(testing: testing, builtin: builtin, features: features) 389 return Args(config: config) 390 } 391 392 struct Args: Encodable { 393 var config: Config 394 } 395 struct Response: Decodable { 396 var versionInfo: VersionInfo 397 } 398 } 399 400 extension WalletModel { 401 /// initalize Wallet-Core. Will do networking 402 @discardableResult 403 nonisolated func setConfig(setTesting: Bool) async throws -> VersionInfo { 404 let request = ConfigRequest(setTesting: setTesting) 405 let response = try await sendRequest(request) 406 return response.versionInfo 407 } 408 } 409 // MARK: - 410 struct InitResponse: Decodable { 411 var versionInfo: VersionInfo 412 var databaseBackend: String? 413 } 414 /// A request to initialize Wallet-core 415 fileprivate struct InitRequest: WalletBackendFormattedRequest { 416 var persistentStoragePath: String 417 var setTesting: Bool 418 419 var operation: String { "init" } 420 func args() -> Args { 421 let testing = Testing(devModeActive: setTesting) 422 let builtin = Builtin(exchanges: []) 423 let features = Features(migrateNativeDb: false, useNativeDb: true) 424 let config = Config(testing: testing, builtin: builtin, features: features) 425 return Args(persistentStoragePath: persistentStoragePath, 426 // cryptoWorkerType: "sync", qtart can ONLY use sync, that's the default anyway 427 logLevel: "info", // trace, info, message, warn, error, none 428 config: config, 429 useNativeLogging: true) 430 } 431 432 struct Args: Encodable { 433 var persistentStoragePath: String 434 // var cryptoWorkerType: String 435 var logLevel: String 436 var config: Config 437 var useNativeLogging: Bool 438 } 439 typealias Response = InitResponse 440 } 441 442 extension WalletModel { 443 /// initalize Wallet-Core. Might do networking 444 nonisolated func initWalletCore(setTesting: Bool, viewHandles: Bool = false) async throws -> InitResponse { 445 let dbPath = try dbPath() 446 // logger.debug("dbPath: \(dbPath)") 447 let request = InitRequest(persistentStoragePath: dbPath, setTesting: setTesting) 448 let response = try await sendRequest(request, viewHandles: viewHandles) // no Delay 449 return response 450 } 451 452 private func dbUrl(_ folder: URL) -> URL { 453 let DATABASE = "talerwalletdb-v30" 454 let dbUrl = folder.appendingPathComponent(DATABASE, isDirectory: false) 455 .appendingPathExtension("sqlite3") 456 return dbUrl 457 } 458 459 private func checkAppSupport(_ url: URL) { 460 let fileManager = FileManager.default 461 var resultStorage: ObjCBool = false 462 463 if !fileManager.fileExists(atPath: url.path, isDirectory: &resultStorage) { 464 do { 465 try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) 466 logger.debug("created \(url.path)") 467 } catch { 468 logger.error("creation failed \(error.localizedDescription)") 469 } 470 } else { 471 // logger.debug("\(url.path) exists") 472 } 473 } 474 475 private func migrate(from source: URL, to target: URL) { 476 let fileManager = FileManager.default 477 let sourceUrl = dbUrl(source) 478 let sourcePath = sourceUrl.path 479 let targetUrl = dbUrl(target) 480 let targetPath = targetUrl.path 481 482 checkAppSupport(target) 483 if fileManager.fileExists(atPath: sourcePath) { 484 do { 485 try fileManager.moveItem(at: sourceUrl, to: targetUrl) 486 logger.debug("migrate: moved to \(target.path)") 487 } catch { 488 logger.error("migrate: move failed \(error.localizedDescription)") 489 } 490 // } else { 491 // logger.debug("migrate: nothing to do, no db at \(sourcePath)") 492 } 493 494 // if fileManager.fileExists(atPath: targetPath) { 495 // logger.debug("found db at \(targetPath)") 496 // } else { 497 // logger.debug("migrate: nothing to do, no db at \(targetPath)") 498 // } 499 } 500 501 private func dbPath() throws -> String { 502 if let docDirUrl = URL.docDirUrl { 503 if let appSupport = URL.appSuppUrl { 504 #if DEBUG || GNU_TALER 505 migrate(from: appSupport, to: docDirUrl) 506 return docDirUrl.path(withSlash: true) 507 #else // TALER_WALLET or TALER_NIGHTLY 508 migrate(from: docDirUrl, to: appSupport) 509 return appSupport.path(withSlash: true) 510 #endif 511 } else { // should never happen 512 logger.error("dbPath: No applicationSupportDirectory") 513 } 514 } else { // should never happen 515 logger.error("dbPath: No documentDirectory") 516 } 517 throw WalletBackendError.initializationError 518 } 519 520 private func cachePath() throws -> String { 521 let fileManager = FileManager.default 522 if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first { 523 let cacheURL = cachesURL.appendingPathComponent("cache.json") 524 let cachePath = cacheURL.path 525 logger.debug("cachePath: \(cachePath)") 526 527 if !fileManager.fileExists(atPath: cachePath) { 528 let contents = Data() /// Initialize an empty `Data`. 529 fileManager.createFile(atPath: cachePath, contents: contents) 530 print("❗️ File \(cachePath) created") 531 } else { 532 print("❗️ File \(cachePath) already exists") 533 } 534 535 return cachePath 536 } else { // should never happen 537 logger.error("cachePath: No cachesDirectory") 538 throw WalletBackendError.initializationError 539 } 540 } 541 } 542 // MARK: - 543 /// A request to migrate the Wallet-core DB from indexed to native sqlite. 544 fileprivate struct MigrateRequest: WalletBackendFormattedRequest { 545 var operation: String { "migrateDatabase" } 546 func args() -> Args { Args(progressToken: operation) } 547 548 struct Args: Encodable { 549 var progressToken: String 550 } 551 typealias Response = MigrationResult // plus notifications 552 } 553 554 struct MigrationResult: Decodable { 555 var migrated: Bool? 556 var databaseBackend: String? 557 } 558 559 extension WalletModel { 560 /// reset Wallet-Core 561 nonisolated func migrateDatabase(viewHandles: Bool = false) 562 async throws -> MigrationResult { 563 let request = MigrateRequest() 564 let controller = Controller.shared 565 controller.progressOperation = request.operation 566 controller.progressToken = request.operation 567 let result = try await sendRequest(request, viewHandles: viewHandles) 568 return result 569 } 570 } 571 // MARK: - 572 /// A request to reset Wallet-core to a virgin DB. WILL DESTROY ALL COINS 573 fileprivate struct ResetRequest: WalletBackendFormattedRequest { 574 var operation: String { "clearDb" } 575 func args() -> Args { Args() } 576 577 struct Args: Encodable {} // no arguments needed 578 struct Response: Decodable {} 579 } 580 581 extension WalletModel { 582 /// reset Wallet-Core 583 nonisolated func resetWalletCore(viewHandles: Bool = false) async throws { 584 let request = ResetRequest() 585 _ = try await sendRequest(request, viewHandles: viewHandles) 586 } 587 } 588 // MARK: - 589 fileprivate struct ExportDbToFile: WalletBackendFormattedRequest { 590 var operation: String { "exportDbToFile" } 591 func args() -> Args { Args(directory: directory, stem: stem, forceFormat: "json") } 592 593 var directory: String 594 var stem: String 595 struct Args: Encodable { 596 var directory: String 597 var stem: String 598 var forceFormat: String 599 } 600 struct Response: Decodable, Sendable { // path of the copied DB 601 var path: String 602 } 603 } 604 605 fileprivate struct ImportDbFromFile: WalletBackendFormattedRequest { 606 var operation: String { "importDbFromFile" } 607 func args() -> Args { Args(path: path ) } 608 609 var path: String 610 struct Args: Encodable { 611 var path: String 612 } 613 struct Response: Decodable {} 614 } 615 616 fileprivate struct GetDiagnostics: WalletBackendFormattedRequest { 617 var operation: String { "getDiagnostics" } 618 func args() -> Args { Args() } 619 struct Args: Encodable {} // no arguments needed 620 typealias Response = String 621 } 622 623 fileprivate struct GetPerformanceStats: WalletBackendFormattedRequest { 624 var operation: String { "testingGetPerformanceStats" } 625 func args() -> Args { Args() } 626 struct Args: Encodable {} // no arguments needed 627 typealias Response = String 628 } 629 630 extension WalletModel { 631 /// export, import DB, get diagnostics 632 nonisolated func exportDbToFile(stem: String, viewHandles: Bool = false) 633 async throws -> String? { 634 if let docDirUrl = URL.docDirUrl { 635 let dbPath = docDirUrl.path(withSlash: false) 636 let request = ExportDbToFile(directory: dbPath, stem: stem) 637 print(dbPath, stem) 638 let response = try await sendRequest(request, viewHandles: viewHandles) 639 return response.path 640 } else { 641 return nil 642 } 643 } 644 nonisolated func importDbFromFile(path: String, viewHandles: Bool = false) 645 async throws { 646 let request = ImportDbFromFile(path: path) 647 _ = try await sendRequest(request, viewHandles: viewHandles) 648 } 649 nonisolated func getDiagnostics(viewHandles: Bool = false) 650 async throws -> String { 651 let request = GetDiagnostics() 652 let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true) 653 return response 654 } 655 nonisolated func getPerformanceStats(viewHandles: Bool = false) 656 async throws -> String { 657 let request = GetPerformanceStats() 658 let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true) 659 return response 660 } 661 } 662 // MARK: - 663 fileprivate struct DevExperimentRequest: WalletBackendFormattedRequest { 664 var operation: String { "applyDevExperiment" } 665 func args() -> Args { Args(devExperimentUri: talerUri) } 666 667 var talerUri: String 668 669 struct Args: Encodable { 670 var devExperimentUri: String 671 } 672 struct Response: Decodable {} 673 } 674 675 extension WalletModel { 676 /// tell wallet-core to mock new transactions 677 nonisolated func devExperimentT(_ talerUri: String, viewHandles: Bool = false) async throws { 678 // T for any Thread 679 let request = DevExperimentRequest(talerUri: talerUri) 680 _ = try await sendRequest(request, viewHandles: viewHandles) 681 } 682 }