taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }