taler-ios

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

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 }