WalletModel.swift (26422B)
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 _ = 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 _ = 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 persistentPath: String 417 var temporaryPath: String? // folder 418 var setTesting: Bool 419 420 var operation: String { "init" } 421 func args() -> Args { 422 let testing = Testing(devModeActive: setTesting) 423 let builtin = Builtin(exchanges: []) 424 let features = Features(migrateNativeDb: false, useNativeDb: true) 425 let config = Config(testing: testing, builtin: builtin, features: features) 426 #if DEBUG 427 let logLevel = "info" // trace, info, message, warn, error, none 428 #else 429 let logLevel = "message" // less logging for AppStore builds 430 #endif 431 return Args(persistentStoragePath: persistentPath, // DB file 432 temporaryStoragePath: temporaryPath, 433 // cryptoWorkerType: "sync", qtart can ONLY use sync, that's the default anyway 434 logLevel: logLevel, 435 config: config, 436 useNativeLogging: true) 437 } 438 439 struct Args: Encodable { 440 var persistentStoragePath: String // DB file 441 var temporaryStoragePath: String? // folder 442 var logLevel: String 443 var config: Config 444 var useNativeLogging: Bool 445 } 446 typealias Response = InitResponse 447 } 448 449 extension WalletModel { 450 /// initalize Wallet-Core. Might do networking 451 nonisolated func initWalletCore(setTesting: Bool, viewHandles: Bool = false) async throws -> InitResponse { 452 let dbPath = try dbPath() 453 let tmpURL = URL.tempDirUrl 454 let request = InitRequest(persistentPath: dbPath, 455 temporaryPath: tmpURL?.path(withSlash: true), 456 setTesting: setTesting) 457 let response = try await sendRequest(request, viewHandles: viewHandles) // no Delay 458 return response 459 } 460 461 private func dbUrl(_ folder: URL) -> URL { 462 let DATABASE = "talerwalletdb-v30" 463 let dbUrl = folder.appendingPathComponent(DATABASE, isDirectory: false) 464 .appendingPathExtension("sqlite3") 465 return dbUrl 466 } 467 468 private func checkAppSupport(_ url: URL) { 469 let fileManager = FileManager.default 470 var resultStorage: ObjCBool = false 471 472 if !fileManager.fileExists(atPath: url.path, isDirectory: &resultStorage) { 473 do { 474 try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) 475 logger.debug("created \(url.path)") 476 } catch { 477 logger.error("creation failed \(error.localizedDescription)") 478 } 479 } else { 480 // logger.debug("\(url.path) exists") 481 } 482 } 483 484 private func migrate(from source: URL, to target: URL) { 485 let fileManager = FileManager.default 486 let sourceUrl = dbUrl(source) 487 let sourcePath = sourceUrl.path 488 let targetUrl = dbUrl(target) 489 let targetPath = targetUrl.path 490 491 checkAppSupport(target) 492 if fileManager.fileExists(atPath: sourcePath) { 493 do { 494 try fileManager.moveItem(at: sourceUrl, to: targetUrl) 495 logger.debug("migrate: moved to \(target.path)") 496 } catch { 497 logger.error("migrate: move failed \(error.localizedDescription)") 498 } 499 // } else { 500 // logger.debug("migrate: nothing to do, no db at \(sourcePath)") 501 } 502 503 // if fileManager.fileExists(atPath: targetPath) { 504 // logger.debug("found db at \(targetPath)") 505 // } else { 506 // logger.debug("migrate: nothing to do, no db at \(targetPath)") 507 // } 508 } 509 510 private func dbPath() throws -> String { 511 if let docDirUrl = URL.docDirUrl { 512 if let appSupport = URL.appSuppUrl { 513 #if DEBUG || GNU_TALER 514 migrate(from: appSupport, to: docDirUrl) 515 return docDirUrl.path(withSlash: true) 516 #else // TALER_WALLET or TALER_NIGHTLY 517 migrate(from: docDirUrl, to: appSupport) 518 return appSupport.path(withSlash: true) 519 #endif 520 } else { // should never happen 521 logger.error("dbPath: No applicationSupportDirectory") 522 } 523 } else { // should never happen 524 logger.error("dbPath: No documentDirectory") 525 } 526 throw WalletBackendError.initializationError 527 } 528 } 529 // MARK: - 530 /// A request to migrate the Wallet-core DB from indexed to native sqlite. 531 fileprivate struct MigrateRequest: WalletBackendFormattedRequest { 532 var operation: String { "migrateDatabase" } 533 func args() -> Args { Args(progressToken: operation) } 534 535 struct Args: Encodable { 536 var progressToken: String 537 } 538 typealias Response = MigrationResult // plus notifications 539 } 540 541 struct MigrationResult: Decodable { 542 var migrated: Bool? 543 var databaseBackend: String? 544 } 545 546 extension WalletModel { 547 /// reset Wallet-Core 548 nonisolated func migrateDatabase(viewHandles: Bool = false) 549 async throws -> MigrationResult { 550 let request = MigrateRequest() 551 let controller = Controller.shared 552 controller.progressOperation = request.operation 553 controller.progressToken = request.operation 554 let result = try await sendRequest(request, viewHandles: viewHandles) 555 return result 556 } 557 } 558 // MARK: - 559 /// A request to reset Wallet-core to a virgin DB. WILL DESTROY ALL COINS 560 fileprivate struct ResetRequest: WalletBackendFormattedRequest { 561 var operation: String { "clearDb" } 562 func args() -> Args { Args() } 563 564 struct Args: Encodable {} // no arguments needed 565 struct Response: Decodable {} 566 } 567 568 extension WalletModel { 569 /// reset Wallet-Core 570 nonisolated func resetWalletCore(viewHandles: Bool = false) async throws { 571 let request = ResetRequest() 572 _ = try await sendRequest(request, viewHandles: viewHandles) 573 } 574 } 575 // MARK: - 576 fileprivate struct ExportDbToFile: WalletBackendFormattedRequest { 577 var operation: String { "exportDbToFile" } 578 func args() -> Args { Args(directory: directory, stem: stem, forceFormat: asJSON ? "json" : nil) } 579 580 var directory: String 581 var stem: String 582 var asJSON: Bool 583 struct Args: Encodable { 584 var directory: String 585 var stem: String 586 var forceFormat: String? 587 } 588 struct Response: Decodable, Sendable { // path of the copied DB 589 var path: String 590 } 591 } 592 593 fileprivate struct ImportDbFromFile: WalletBackendFormattedRequest { 594 var operation: String { "importDbFromFile" } 595 func args() -> Args { Args(path: path ) } 596 597 var path: String 598 struct Args: Encodable { 599 var path: String 600 } 601 struct Response: Decodable {} 602 } 603 604 fileprivate struct GetDiagnostics: WalletBackendFormattedRequest { 605 var operation: String { "getDiagnostics" } 606 func args() -> Args { Args() } 607 struct Args: Encodable {} // no arguments needed 608 typealias Response = String 609 } 610 611 fileprivate struct GetPerformanceStats: WalletBackendFormattedRequest { 612 var operation: String { "testingGetPerformanceStats" } 613 func args() -> Args { Args() } 614 struct Args: Encodable {} // no arguments needed 615 typealias Response = String 616 } 617 618 extension WalletModel { 619 /// export, import DB, get diagnostics 620 nonisolated func exportDbToFile(stem: String, asJSON: Bool = true, viewHandles: Bool = false) 621 async throws -> String? { 622 if let docDirUrl = URL.docDirUrl { 623 let dbPath = docDirUrl.path(withSlash: false) 624 let request = ExportDbToFile(directory: dbPath, stem: stem, asJSON: asJSON) 625 print(dbPath, stem) 626 let response = try await sendRequest(request, viewHandles: viewHandles) 627 return response.path 628 } else { 629 return nil 630 } 631 } 632 nonisolated func importDbFromFile(path: String, viewHandles: Bool = false) 633 async throws { 634 let request = ImportDbFromFile(path: path) 635 _ = try await sendRequest(request, viewHandles: viewHandles) 636 } 637 nonisolated func getDiagnostics(viewHandles: Bool = false) 638 async throws -> String { 639 let request = GetDiagnostics() 640 let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true) 641 return response 642 } 643 nonisolated func getPerformanceStats(viewHandles: Bool = false) 644 async throws -> String { 645 let request = GetPerformanceStats() 646 let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true) 647 return response 648 } 649 } 650 // MARK: - 651 fileprivate struct DevExperimentRequest: WalletBackendFormattedRequest { 652 var operation: String { "applyDevExperiment" } 653 func args() -> Args { Args(devExperimentUri: talerUri) } 654 655 var talerUri: String 656 657 struct Args: Encodable { 658 var devExperimentUri: String 659 } 660 struct Response: Decodable {} 661 } 662 663 extension WalletModel { 664 /// tell wallet-core to mock new transactions 665 nonisolated func devExperimentT(_ talerUri: String, viewHandles: Bool = false) async throws { 666 // T for any Thread 667 let request = DevExperimentRequest(talerUri: talerUri) 668 _ = try await sendRequest(request, viewHandles: viewHandles) 669 } 670 }