WalletModel.swift (25924B)
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 // needed only once to migrate the DB 370 } 371 372 fileprivate struct Config: Encodable { 373 var testing: Testing 374 var builtin: Builtin 375 var features: Features 376 } 377 // MARK: - 378 /// A request to re-configure Wallet-core 379 fileprivate struct ConfigRequest: WalletBackendFormattedRequest { 380 var setTesting: Bool 381 382 var operation: String { "setWalletRunConfig" } 383 func args() -> Args { 384 let testing = Testing(devModeActive: setTesting) 385 let builtin = Builtin(exchanges: []) 386 let features = Features(migrateNativeDb: false) 387 let config = Config(testing: testing, builtin: builtin, features: features) 388 return Args(config: config) 389 } 390 391 struct Args: Encodable { 392 var config: Config 393 } 394 struct Response: Decodable { 395 var versionInfo: VersionInfo 396 } 397 } 398 399 extension WalletModel { 400 /// initalize Wallet-Core. Will do networking 401 @discardableResult 402 nonisolated func setConfig(setTesting: Bool) async throws -> VersionInfo { 403 let request = ConfigRequest(setTesting: setTesting) 404 let response = try await sendRequest(request) 405 return response.versionInfo 406 } 407 } 408 // MARK: - 409 /// A request to initialize Wallet-core 410 fileprivate struct InitRequest: WalletBackendFormattedRequest { 411 var persistentStoragePath: String 412 var setTesting: Bool 413 var migrateNativeDb: Bool 414 415 var operation: String { "init" } 416 func args() -> Args { 417 let testing = Testing(devModeActive: setTesting) 418 let builtin = Builtin(exchanges: []) 419 let features = Features(migrateNativeDb: migrateNativeDb) 420 let config = Config(testing: testing, builtin: builtin, features: features) 421 return Args(persistentStoragePath: persistentStoragePath, 422 // cryptoWorkerType: "sync", qtart can ONLY use sync, that's the default anyway 423 logLevel: "info", // trace, info, message, warn, error, none 424 config: config, 425 useNativeLogging: true) 426 } 427 428 struct Args: Encodable { 429 var persistentStoragePath: String 430 // var cryptoWorkerType: String 431 var logLevel: String 432 var config: Config 433 var useNativeLogging: Bool 434 } 435 struct Response: Decodable { 436 var versionInfo: VersionInfo 437 } 438 } 439 440 extension WalletModel { 441 /// initalize Wallet-Core. Might do networking 442 nonisolated func initWalletCore(setTesting: Bool, migrateNativeDb: Bool, viewHandles: Bool = false) async throws -> VersionInfo { 443 let dbPath = try dbPath() 444 // logger.debug("dbPath: \(dbPath)") 445 let request = InitRequest(persistentStoragePath: dbPath, setTesting: setTesting, migrateNativeDb: migrateNativeDb) 446 let response = try await sendRequest(request, viewHandles: viewHandles) // no Delay 447 return response.versionInfo 448 } 449 450 private func dbUrl(_ folder: URL) -> URL { 451 let DATABASE = "talerwalletdb-v30" 452 let dbUrl = folder.appendingPathComponent(DATABASE, isDirectory: false) 453 .appendingPathExtension("sqlite3") 454 return dbUrl 455 } 456 457 private func checkAppSupport(_ url: URL) { 458 let fileManager = FileManager.default 459 var resultStorage: ObjCBool = false 460 461 if !fileManager.fileExists(atPath: url.path, isDirectory: &resultStorage) { 462 do { 463 try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) 464 logger.debug("created \(url.path)") 465 } catch { 466 logger.error("creation failed \(error.localizedDescription)") 467 } 468 } else { 469 // logger.debug("\(url.path) exists") 470 } 471 } 472 473 private func migrate(from source: URL, to target: URL) { 474 let fileManager = FileManager.default 475 let sourceUrl = dbUrl(source) 476 let sourcePath = sourceUrl.path 477 let targetUrl = dbUrl(target) 478 let targetPath = targetUrl.path 479 480 checkAppSupport(target) 481 if fileManager.fileExists(atPath: sourcePath) { 482 do { 483 try fileManager.moveItem(at: sourceUrl, to: targetUrl) 484 logger.debug("migrate: moved to \(target.path)") 485 } catch { 486 logger.error("migrate: move failed \(error.localizedDescription)") 487 } 488 // } else { 489 // logger.debug("migrate: nothing to do, no db at \(sourcePath)") 490 } 491 492 // if fileManager.fileExists(atPath: targetPath) { 493 // logger.debug("found db at \(targetPath)") 494 // } else { 495 // logger.debug("migrate: nothing to do, no db at \(targetPath)") 496 // } 497 } 498 499 private func dbPath() throws -> String { 500 if let docDirUrl = URL.docDirUrl { 501 if let appSupport = URL.appSuppUrl { 502 #if DEBUG || GNU_TALER 503 migrate(from: appSupport, to: docDirUrl) 504 return docDirUrl.path(withSlash: true) 505 #else // TALER_WALLET or TALER_NIGHTLY 506 migrate(from: docDirUrl, to: appSupport) 507 return appSupport.path(withSlash: true) 508 #endif 509 } else { // should never happen 510 logger.error("dbPath: No applicationSupportDirectory") 511 } 512 } else { // should never happen 513 logger.error("dbPath: No documentDirectory") 514 } 515 throw WalletBackendError.initializationError 516 } 517 518 private func cachePath() throws -> String { 519 let fileManager = FileManager.default 520 if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first { 521 let cacheURL = cachesURL.appendingPathComponent("cache.json") 522 let cachePath = cacheURL.path 523 logger.debug("cachePath: \(cachePath)") 524 525 if !fileManager.fileExists(atPath: cachePath) { 526 let contents = Data() /// Initialize an empty `Data`. 527 fileManager.createFile(atPath: cachePath, contents: contents) 528 print("❗️ File \(cachePath) created") 529 } else { 530 print("❗️ File \(cachePath) already exists") 531 } 532 533 return cachePath 534 } else { // should never happen 535 logger.error("cachePath: No cachesDirectory") 536 throw WalletBackendError.initializationError 537 } 538 } 539 } 540 // MARK: - 541 /// A request to reset Wallet-core to a virgin DB. WILL DESTROY ALL COINS 542 fileprivate struct ResetRequest: WalletBackendFormattedRequest { 543 var operation: String { "clearDb" } 544 func args() -> Args { Args() } 545 546 struct Args: Encodable {} // no arguments needed 547 struct Response: Decodable {} 548 } 549 550 extension WalletModel { 551 /// reset Wallet-Core 552 nonisolated func resetWalletCore(viewHandles: Bool = false) async throws { 553 let request = ResetRequest() 554 _ = try await sendRequest(request, viewHandles: viewHandles) 555 } 556 } 557 // MARK: - 558 fileprivate struct ExportDbToFile: WalletBackendFormattedRequest { 559 var operation: String { "exportDbToFile" } 560 func args() -> Args { Args(directory: directory, stem: stem, forceFormat: "json") } 561 562 var directory: String 563 var stem: String 564 struct Args: Encodable { 565 var directory: String 566 var stem: String 567 var forceFormat: String 568 } 569 struct Response: Decodable, Sendable { // path of the copied DB 570 var path: String 571 } 572 } 573 574 fileprivate struct ImportDbFromFile: WalletBackendFormattedRequest { 575 var operation: String { "importDbFromFile" } 576 func args() -> Args { Args(path: path ) } 577 578 var path: String 579 struct Args: Encodable { 580 var path: String 581 } 582 struct Response: Decodable {} 583 } 584 585 fileprivate struct GetDiagnostics: WalletBackendFormattedRequest { 586 var operation: String { "getDiagnostics" } 587 func args() -> Args { Args() } 588 struct Args: Encodable {} // no arguments needed 589 typealias Response = String 590 } 591 592 fileprivate struct GetPerformanceStats: WalletBackendFormattedRequest { 593 var operation: String { "testingGetPerformanceStats" } 594 func args() -> Args { Args() } 595 struct Args: Encodable {} // no arguments needed 596 typealias Response = String 597 } 598 599 extension WalletModel { 600 /// export, import DB, get diagnostics 601 nonisolated func exportDbToFile(stem: String, viewHandles: Bool = false) 602 async throws -> String? { 603 if let docDirUrl = URL.docDirUrl { 604 let dbPath = docDirUrl.path(withSlash: false) 605 let request = ExportDbToFile(directory: dbPath, stem: stem) 606 print(dbPath, stem) 607 let response = try await sendRequest(request, viewHandles: viewHandles) 608 return response.path 609 } else { 610 return nil 611 } 612 } 613 nonisolated func importDbFromFile(path: String, viewHandles: Bool = false) 614 async throws { 615 let request = ImportDbFromFile(path: path) 616 _ = try await sendRequest(request, viewHandles: viewHandles) 617 } 618 nonisolated func getDiagnostics(viewHandles: Bool = false) 619 async throws -> String { 620 let request = GetDiagnostics() 621 let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true) 622 return response 623 } 624 nonisolated func getPerformanceStats(viewHandles: Bool = false) 625 async throws -> String { 626 let request = GetPerformanceStats() 627 let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true) 628 return response 629 } 630 } 631 // MARK: - 632 fileprivate struct DevExperimentRequest: WalletBackendFormattedRequest { 633 var operation: String { "applyDevExperiment" } 634 func args() -> Args { Args(devExperimentUri: talerUri) } 635 636 var talerUri: String 637 638 struct Args: Encodable { 639 var devExperimentUri: String 640 } 641 struct Response: Decodable {} 642 } 643 644 extension WalletModel { 645 /// tell wallet-core to mock new transactions 646 nonisolated func devExperimentT(_ talerUri: String, viewHandles: Bool = false) async throws { 647 // T for any Thread 648 let request = DevExperimentRequest(talerUri: talerUri) 649 _ = try await sendRequest(request, viewHandles: viewHandles) 650 } 651 }