taler-ios

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

WalletModel.swift (27979B)


      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, UInt) {    // 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, id)
    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         let (response, id) = try await sendRequest(request)
    249         return response
    250     }
    251 }
    252 // MARK: -
    253 struct WaitTransactionResult: Decodable, Sendable {
    254     var matched: String?
    255     var txState: TransactionState?
    256 }
    257 
    258 /// A request to wait for a background wallet transaction.
    259 fileprivate struct WaitTransactionState: WalletBackendFormattedRequest {
    260     typealias Response = WaitTransactionResult
    261     var operation: String { "waitTransactionState" }
    262     func args() -> Args { Args(transactionId: txId, txState: txState, bailStates: "final", progressToken: token) }
    263     var txId: String
    264     var txState: TransactionState
    265     var token: String
    266 
    267     struct Args: Encodable {
    268         var transactionId: String
    269         var txState: TransactionState
    270         var bailStates: String
    271         var progressToken: String
    272     }
    273 }
    274 // MARK: -
    275 /// A request to cancel a wallet transaction by token.
    276 fileprivate struct CancelProgressToken: WalletBackendFormattedRequest {
    277     struct Response: Decodable {}
    278     var operation: String { "cancelProgressToken" }
    279     func args() -> Args { Args(operation: op, progressToken: token) }
    280 
    281     var op: String
    282     var token: String
    283 
    284     struct Args: Encodable {
    285         var operation: String
    286         var progressToken: String
    287     }
    288 }
    289 /// A request to retry a wallet transaction by token.
    290 fileprivate struct RetryProgressTokenNow: WalletBackendFormattedRequest {
    291     struct Response: Decodable {}
    292     var operation: String { "retryProgressTokenNow" }
    293     func args() -> Args { Args(operation: op, progressToken: token) }
    294 
    295     var op: String
    296     var token: String
    297 
    298     struct Args: Encodable {
    299         var operation: String
    300         var progressToken: String
    301     }
    302 }
    303 // MARK: -
    304 /// A request to get a wallet transaction by ID.
    305 fileprivate struct GetTransactionById: WalletBackendFormattedRequest {
    306     typealias Response = TalerTransaction
    307     var operation: String { "getTransactionById" }
    308     func args() -> Args { Args(transactionId: txId, includeContractTerms: inclTerms) }
    309 
    310     var txId: String
    311     var inclTerms: Bool?
    312 
    313     struct Args: Encodable {
    314         var transactionId: String
    315         var includeContractTerms: Bool?
    316     }
    317 }
    318 
    319 fileprivate struct JSONTransactionById: WalletBackendFormattedRequest {
    320     typealias Response = String
    321     var operation: String { "getTransactionById" }
    322     func args() -> Args { Args(transactionId: transactionId, includeContractTerms: includeContractTerms) }
    323 
    324     var transactionId: String
    325     var includeContractTerms: Bool?
    326 
    327     struct Args: Encodable {
    328         var transactionId: String
    329         var includeContractTerms: Bool?
    330     }
    331 }
    332 
    333 extension WalletModel {
    334     nonisolated func waitTransactionState(_ txId: String, token: String,
    335                                            major: TransactionMajorState)
    336       async throws -> WaitTransactionResult {
    337         let txState = TransactionState(major: major, minor: .any, working: nil)
    338         let request = WaitTransactionState(txId: txId, txState: txState, token: token)
    339         let controller = Controller.shared
    340         controller.progressOperation = request.operation
    341         controller.progressToken = txId
    342         controller.lastProgressError = nil
    343         controller.lastProgressPhase = nil
    344         let (response, id) = try await sendRequest(request)
    345         return response
    346     }
    347     nonisolated func cancelProgressToken(_ op: String, token: String)
    348       async throws {
    349         let request = CancelProgressToken(op: op, token: token)
    350         _ = try await sendRequest(request)
    351     }
    352     nonisolated func retryProgressTokenNow(_ op: String, token: String)
    353       async throws {
    354         let request = RetryProgressTokenNow(op: op, token: token)
    355         _ = try await sendRequest(request)
    356     }
    357     /// get the specified transaction from Wallet-Core. No networking involved
    358     nonisolated func getTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false)
    359       async throws -> TalerTransaction {
    360         let request = GetTransactionById(txId: transactionId, inclTerms: includeContractTerms)
    361           let (response, id) = try await sendRequest(request, viewHandles: viewHandles)
    362           return response
    363     }
    364     nonisolated func jsonTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false)
    365       async throws -> String {
    366         let request = JSONTransactionById(transactionId: transactionId, includeContractTerms: includeContractTerms)
    367         let (response, id) = try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    368         return response
    369     }
    370 }
    371 // MARK: -
    372 /// The info returned from Wallet-core init
    373 struct VersionInfo: Decodable {
    374     var implementationSemver: String?
    375     var implementationGitHash: String?
    376     var version: String
    377     var exchange: String
    378     var merchant: String
    379     var bank: String
    380 }
    381 // MARK: -
    382 fileprivate struct Testing: Encodable {
    383     var denomselAllowLate: Bool
    384     var devModeActive: Bool
    385     var insecureTrustExchange: Bool
    386     var preventThrottling: Bool
    387     var skipDefaults: Bool
    388     var emitObservabilityEvents: Bool
    389     // more to come...
    390 
    391     init(devModeActive: Bool) {
    392         self.denomselAllowLate = false
    393         self.devModeActive = devModeActive
    394         self.insecureTrustExchange = false
    395         self.preventThrottling = false
    396         self.skipDefaults = false
    397         self.emitObservabilityEvents = devModeActive
    398     }
    399 }
    400 
    401 fileprivate struct Builtin: Encodable {
    402     var exchanges: [String]
    403     // more to come...
    404 }
    405 
    406 fileprivate struct Features: Encodable {
    407     var migrateNativeDb: Bool           // obsolete - use migrateDatabase() instead
    408     var useNativeDb: Bool
    409 }
    410 
    411 fileprivate struct Config: Encodable {
    412     var testing: Testing
    413     var builtin: Builtin
    414     var features: Features
    415 }
    416 // MARK: -
    417 ///  A request to re-configure Wallet-core
    418 fileprivate struct ConfigRequest: WalletBackendFormattedRequest {
    419     var setTesting: Bool
    420 
    421     var operation: String { "setWalletRunConfig" }
    422     func args() -> Args {
    423         let testing = Testing(devModeActive: setTesting)
    424         let builtin = Builtin(exchanges: [])
    425         let features = Features(migrateNativeDb: false, useNativeDb: true)
    426         let config = Config(testing: testing, builtin: builtin, features: features)
    427         return Args(config: config)
    428     }
    429 
    430     struct Args: Encodable {
    431         var config: Config
    432     }
    433     struct Response: Decodable {
    434         var versionInfo: VersionInfo
    435     }
    436 }
    437 
    438 extension WalletModel {
    439     /// initalize Wallet-Core. Will do networking
    440     @discardableResult
    441     nonisolated func setConfig(setTesting: Bool) async throws -> VersionInfo {
    442         let request = ConfigRequest(setTesting: setTesting)
    443         let (response, id) = try await sendRequest(request)
    444         return response.versionInfo
    445     }
    446 }
    447 // MARK: -
    448 struct InitResponse: Decodable {
    449     var versionInfo: VersionInfo
    450     var databaseBackend: String?
    451 }
    452 ///  A request to initialize Wallet-core
    453 fileprivate struct InitRequest: WalletBackendFormattedRequest {
    454     var persistentPath: String
    455     var temporaryPath: String?       // folder
    456     var setTesting: Bool
    457 
    458     var operation: String { "init" }
    459     func args() -> Args {
    460         let testing = Testing(devModeActive: setTesting)
    461         let builtin = Builtin(exchanges: [])
    462         let features = Features(migrateNativeDb: false, useNativeDb: true)
    463         let config = Config(testing: testing, builtin: builtin, features: features)
    464 #if DEBUG
    465         let logLevel = "info"       // trace, info, message, warn, error, none
    466 #else
    467         let logLevel = "message"    // less logging for AppStore builds
    468 #endif
    469         return Args(persistentStoragePath: persistentPath,  // DB file
    470                      temporaryStoragePath: temporaryPath,
    471 //                       cryptoWorkerType: "sync",  qtart can ONLY use sync, that's the default anyway
    472                                  logLevel: logLevel,
    473                                    config: config,
    474                          useNativeLogging: true)
    475     }
    476 
    477     struct Args: Encodable {
    478         var persistentStoragePath: String       // DB file
    479         var temporaryStoragePath: String?       // folder
    480         var logLevel: String
    481         var config: Config
    482         var useNativeLogging: Bool
    483     }
    484     typealias Response = InitResponse
    485 }
    486 
    487 extension WalletModel {
    488     /// initalize Wallet-Core. Might do networking
    489     nonisolated func initWalletCore(setTesting: Bool, viewHandles: Bool = false) async throws -> InitResponse {
    490         let dbPath = try dbPath()
    491         let tmpURL = URL.tempDirUrl
    492         let request = InitRequest(persistentPath: dbPath,
    493                                    temporaryPath: tmpURL?.path(withSlash: true),
    494                                       setTesting: setTesting)
    495         let (response, id) = try await sendRequest(request, viewHandles: viewHandles)
    496         return response
    497     }
    498 
    499     private func dbUrl(_ folder: URL) -> URL {
    500         let DATABASE = "talerwalletdb-v30"
    501         let dbUrl = folder.appendingPathComponent(DATABASE, isDirectory: false)
    502                           .appendingPathExtension("sqlite3")
    503         return dbUrl
    504     }
    505 
    506     private func checkAppSupport(_ url: URL) {
    507         let fileManager = FileManager.default
    508         var resultStorage: ObjCBool = false
    509 
    510         if !fileManager.fileExists(atPath: url.path, isDirectory: &resultStorage) {
    511             do {
    512                 try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
    513                 logger.debug("created \(url.path)")
    514             } catch {
    515                 logger.error("creation failed \(error.localizedDescription)")
    516             }
    517         } else {
    518 //            logger.debug("\(url.path) exists")
    519         }
    520     }
    521 
    522     private func migrate(from source: URL, to target: URL) {
    523         let fileManager = FileManager.default
    524         let sourceUrl = dbUrl(source)
    525         let sourcePath = sourceUrl.path
    526         let targetUrl = dbUrl(target)
    527         let targetPath = targetUrl.path
    528 
    529         checkAppSupport(target)
    530         if fileManager.fileExists(atPath: sourcePath) {
    531             do {
    532                 try fileManager.moveItem(at: sourceUrl, to: targetUrl)
    533                 logger.debug("migrate: moved to \(target.path)")
    534             } catch {
    535                 logger.error("migrate: move failed \(error.localizedDescription)")
    536             }
    537 //        } else {
    538 //            logger.debug("migrate: nothing to do, no db at \(sourcePath)")
    539         }
    540 
    541 //        if fileManager.fileExists(atPath: targetPath) {
    542 //            logger.debug("found db at \(targetPath)")
    543 //        } else {
    544 //            logger.debug("migrate: nothing to do, no db at \(targetPath)")
    545 //        }
    546     }
    547 
    548     private func dbPath() throws -> String {
    549         if let docDirUrl = URL.docDirUrl {
    550             if let appSupport = URL.appSuppUrl {
    551 #if DEBUG || GNU_TALER
    552                 migrate(from: appSupport, to: docDirUrl)
    553                 return docDirUrl.path(withSlash: true)
    554 #else // TALER_WALLET or TALER_NIGHTLY
    555                 migrate(from: docDirUrl, to: appSupport)
    556                 return appSupport.path(withSlash: true)
    557 #endif
    558             } else { // should never happen
    559                 logger.error("dbPath: No applicationSupportDirectory")
    560             }
    561         } else { // should never happen
    562             logger.error("dbPath: No documentDirectory")
    563         }
    564         throw WalletBackendError.initializationError
    565     }
    566 }
    567 // MARK: -
    568 ///  A request to migrate the Wallet-core DB from indexed to native sqlite.
    569 fileprivate struct MigrateRequest: WalletBackendFormattedRequest {
    570     var operation: String { "migrateDatabase" }
    571     func args() -> Args { Args(progressToken: operation) }
    572 
    573     struct Args: Encodable {
    574         var progressToken: String
    575     }
    576     typealias Response = MigrationResult                      // plus notifications
    577 }
    578 
    579 struct MigrationResult: Decodable {
    580     var migrated: Bool?
    581     var databaseBackend: String?
    582 }
    583 
    584 extension WalletModel {
    585     /// reset Wallet-Core
    586     nonisolated func migrateDatabase(viewHandles: Bool = false)
    587       async throws -> MigrationResult {
    588         let request = MigrateRequest()
    589         let controller = Controller.shared
    590         controller.progressOperation = request.operation
    591         controller.progressToken = request.operation
    592         let (response, id) = try await sendRequest(request, viewHandles: viewHandles)
    593         return response
    594     }
    595 }
    596 // MARK: -
    597 ///  A request to reset Wallet-core to a virgin DB. WILL DESTROY ALL COINS
    598 fileprivate struct ResetRequest: WalletBackendFormattedRequest {
    599     var operation: String { "clearDb" }
    600     func args() -> Args { Args() }
    601 
    602     struct Args: Encodable {}                           // no arguments needed
    603     struct Response: Decodable {}
    604 }
    605 
    606 extension WalletModel {
    607     /// reset Wallet-Core
    608     nonisolated func resetWalletCore(viewHandles: Bool = false) async throws {
    609         let request = ResetRequest()
    610         _ = try await sendRequest(request, viewHandles: viewHandles)
    611     }
    612 }
    613 // MARK: -
    614 fileprivate struct ExportDbToFile: WalletBackendFormattedRequest {
    615     var operation: String { "exportDbToFile" }
    616     func args() -> Args { Args(directory: directory, stem: stem, forceFormat: asJSON ? "json" : nil) }
    617 
    618     var directory: String
    619     var stem: String
    620     var asJSON: Bool
    621     struct Args: Encodable {
    622         var directory: String
    623         var stem: String
    624         var forceFormat: String?
    625     }
    626     struct Response: Decodable, Sendable {              // path of the copied DB
    627         var path: String
    628     }
    629 }
    630 
    631 fileprivate struct ImportDbFromFile: WalletBackendFormattedRequest {
    632     var operation: String { "importDbFromFile" }
    633     func args() -> Args { Args(path: path ) }
    634 
    635     var path: String
    636     struct Args: Encodable {
    637         var path: String
    638     }
    639     struct Response: Decodable {}
    640 }
    641 
    642 fileprivate struct GetDiagnostics: WalletBackendFormattedRequest {
    643     var operation: String { "getDiagnostics" }
    644     func args() -> Args { Args() }
    645     struct Args: Encodable {}                           // no arguments needed
    646     typealias Response = String
    647 }
    648 
    649 fileprivate struct GetPerformanceStats: WalletBackendFormattedRequest {
    650     var operation: String { "testingGetPerformanceStats" }
    651     func args() -> Args { Args() }
    652     struct Args: Encodable {}                           // no arguments needed
    653     typealias Response = String
    654 }
    655 
    656 extension WalletModel {
    657     /// export, import DB, get diagnostics
    658     nonisolated func exportDbToFile(stem: String, asJSON: Bool = true, viewHandles: Bool = false)
    659       async throws -> String? {
    660         if let docDirUrl = URL.docDirUrl {
    661             let dbPath = docDirUrl.path(withSlash: false)
    662             let request = ExportDbToFile(directory: dbPath, stem: stem, asJSON: asJSON)
    663     print(dbPath, stem)
    664             let (response, id) = try await sendRequest(request, viewHandles: viewHandles)
    665             return response.path
    666         } else {
    667             return nil
    668         }
    669     }
    670     nonisolated func importDbFromFile(path: String, viewHandles: Bool = false)
    671       async throws {
    672         let request = ImportDbFromFile(path: path)
    673         _ = try await sendRequest(request, viewHandles: viewHandles)
    674     }
    675     nonisolated func getDiagnostics(viewHandles: Bool = false)
    676       async throws -> String {
    677         let request = GetDiagnostics()
    678         let (response, id) = try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    679         return response
    680     }
    681     nonisolated func getPerformanceStats(viewHandles: Bool = false)
    682     async throws -> String {
    683         let request = GetPerformanceStats()
    684         let (response, id) = try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    685         return response
    686     }
    687 }
    688 // MARK: -
    689 fileprivate struct DevExperimentRequest: WalletBackendFormattedRequest {
    690     var operation: String { "applyDevExperiment" }
    691     func args() -> Args { Args(devExperimentUri: talerUri) }
    692 
    693     var talerUri: String
    694 
    695     struct Args: Encodable {
    696         var devExperimentUri: String
    697     }
    698     struct Response: Decodable {}
    699 }
    700 
    701 extension WalletModel {
    702     /// tell wallet-core to mock new transactions
    703     nonisolated func devExperimentT(_ talerUri: String, viewHandles: Bool = false) async throws {
    704         // T for any Thread
    705         let request = DevExperimentRequest(talerUri: talerUri)
    706         _ = try await sendRequest(request, viewHandles: viewHandles)
    707     }
    708 }