taler-ios

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

WalletModel.swift (20886B)


      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 
     32     func localizedCause(_ currency: String) -> String {
     33         switch self {
     34             case .merchantAcceptInsufficient:
     35                 String(localized: "payment_balance_insufficient_hint_merchant_accept_insufficient",
     36                     defaultValue: "Merchant doesn't accept money from one or more providers in this wallet")
     37             case .merchantDepositInsufficient:
     38                 String(localized: "payment_balance_insufficient_hint_merchant_deposit_insufficient",
     39                     defaultValue: "Merchant doesn't accept the wire method of the provider, this likely means it is misconfigured")
     40             case .ageRestricted:
     41                 String(localized: "payment_balance_insufficient_hint_age_restricted",
     42                     defaultValue: "Purchase not possible due to age restriction")
     43             case .walletBalanceMaterialInsufficient:
     44                 String(localized: "payment_balance_insufficient_hint_wallet_balance_material_insufficient",
     45                     defaultValue: "Some of the digital cash needed for this purchase is currently unavailable")
     46             case .walletBalanceAvailableInsufficient:
     47                 String(localized: "payment_balance_insufficient_max",
     48                     defaultValue: "Balance insufficient! You don't have enough \(currency).")
     49             case .exchangeMissingGlobalFees:
     50                 String(localized: "payment_balance_insufficient_hint_exchange_missing_global_fees",
     51                     defaultValue: "Provider is missing the global fee configuration, this likely means it is misconfigured")
     52             case .feesNotCovered:
     53                 String(localized: "payment_balance_insufficient_hint_fees_not_covered",
     54                     defaultValue: "Not enough funds to pay the provider fees not covered by the merchant")
     55         }
     56     }
     57 }
     58 
     59 struct InsufficientBalanceDetailsPerExchange: Codable, Hashable {
     60     var balanceAvailable: Amount
     61     var balanceMaterial: Amount
     62     var balanceExchangeDepositable: Amount
     63     var balanceAgeAcceptable: Amount
     64     var balanceReceiverAcceptable: Amount
     65     var balanceReceiverDepositable: Amount
     66     var maxEffectiveSpendAmount: Amount
     67     /// Exchange doesn't have global fees configured for the relevant year, p2p payments aren't possible.
     68     var missingGlobalFees: Bool
     69 }
     70 
     71 ///  Detailed reason for why the wallet's balance is insufficient.
     72 struct PaymentInsufficientBalanceDetails: Codable, Hashable {
     73     /// Amount requested by the merchant.
     74     var amountRequested: Amount
     75     var causeHint: InsufficientBalanceHint?
     76     /// Balance of type "available" (see balance.ts for definition).
     77     var balanceAvailable: Amount
     78     /// Balance of type "material" (see balance.ts for definition).
     79     var balanceMaterial: Amount
     80     /// Balance of type "age-acceptable" (see balance.ts for definition).
     81     var balanceAgeAcceptable: Amount
     82     /// Balance of type "merchant-acceptable" (see balance.ts for definition).
     83     var balanceReceiverAcceptable: Amount
     84     /// Balance of type ...
     85     var balanceReceiverDepositable: Amount
     86     var balanceExchangeDepositable: Amount
     87     /// Maximum effective amount that the wallet can spend, when all fees are paid by the wallet.
     88     var maxEffectiveSpendAmount: Amount
     89     var perExchange: [String : InsufficientBalanceDetailsPerExchange]
     90 }
     91 // MARK: -
     92 struct TalerErrorDetail: Codable, Hashable {
     93     /// Numeric error code defined in the GANA gnu-taler-error-codes registry.
     94     var code: Int
     95     // all other fields are optional:
     96     var when: Timestamp?
     97     /// English description of the error code.
     98     var hint: String?
     99 
    100     /// Error details, type depends on `talerErrorCode`.
    101     var detail: String?
    102 
    103     /// HTTPError
    104     var requestUrl: String?
    105     var requestMethod: String?
    106     var httpStatusCode: Int?
    107     var stack: String?
    108 
    109     var insufficientBalanceDetails: PaymentInsufficientBalanceDetails?
    110 }
    111 // MARK: -
    112 /// Communicate with wallet-core
    113 class WalletModel: ObservableObject {
    114     public static let shared = WalletModel()
    115     let logger = Logger(subsystem: "net.taler.gnu", category: "WalletModel")
    116 
    117     @Published var error2: ErrorData? = nil
    118 
    119     @MainActor func setError(_ theError: Error?) {
    120         if let theError {
    121             self.error2 = .error(theError)
    122         } else {
    123             self.error2 = nil
    124         }
    125     }
    126     @MainActor func setMessage(_ title: String,_ theMessage: String?) {
    127         if let theMessage {
    128             self.error2 = .message(title: title, message: theMessage)
    129         } else {
    130             self.error2 = nil
    131         }
    132     }
    133 
    134     func sendRequest<T: WalletBackendFormattedRequest> (_ request: T, viewHandles: Bool = false, asJSON: Bool = false)
    135       async throws -> T.Response {    // T for any Thread
    136 #if !DEBUG
    137         logger.log("sending: \(request.operation(), privacy: .public)")
    138 #endif
    139         let sendTime = Date.now
    140         do {
    141             let (response, id) = try await WalletCore.shared.sendFormattedRequest(request, asJSON: asJSON)
    142 #if !DEBUG
    143             let timeUsed = Date.now - sendTime
    144             logger.log("received: \(request.operation(), privacy: .public) (\(id, privacy: .public)) after \(timeUsed.milliseconds, privacy: .public) ms")
    145 #endif
    146             return response
    147         } catch {       // rethrows
    148             let timeUsed = Date.now - sendTime
    149             logger.error("\(request.operation(), privacy: .public) failed after \(timeUsed.milliseconds, privacy: .public) ms\n\(error, privacy: .public)")
    150             if !viewHandles {
    151                 // TODO: symlog + controller sound
    152                 await setError(error)
    153             }
    154             throw error
    155         }
    156     }
    157 }
    158 // MARK: -
    159 /// A request to tell wallet-core about the network.
    160 fileprivate struct ApplicationResumedRequest: WalletBackendFormattedRequest {
    161     struct Response: Decodable {}
    162     func operation() -> String { "hintApplicationResumed" }
    163     func args() -> Args { Args() }
    164 
    165     struct Args: Encodable {}                           // no arguments needed
    166 }
    167 
    168 fileprivate struct NetworkAvailabilityRequest: WalletBackendFormattedRequest {
    169     struct Response: Decodable {}
    170     func operation() -> String { "hintNetworkAvailability" }
    171     func args() -> Args { Args(isNetworkAvailable: isNetworkAvailable) }
    172 
    173     var isNetworkAvailable: Bool
    174 
    175     struct Args: Encodable {
    176         var isNetworkAvailable: Bool
    177     }
    178 }
    179 
    180 extension WalletModel {
    181     func hintNetworkAvailabilityT(_ isNetworkAvailable: Bool = false) async {
    182         // T for any Thread
    183         let request = NetworkAvailabilityRequest(isNetworkAvailable: isNetworkAvailable)
    184         _ = try? await sendRequest(request)
    185     }
    186     func hintApplicationResumedT() async {
    187         // T for any Thread
    188         let request = ApplicationResumedRequest()
    189         _ = try? await sendRequest(request)
    190     }
    191 }
    192 // MARK: -
    193 /// A request to get a wallet transaction by ID.
    194 fileprivate struct GetTransactionById: WalletBackendFormattedRequest {
    195     typealias Response = TalerTransaction
    196     func operation() -> String { "getTransactionById" }
    197     func args() -> Args { Args(transactionId: transactionId, includeContractTerms: includeContractTerms) }
    198 
    199     var transactionId: String
    200     var includeContractTerms: Bool?
    201 
    202     struct Args: Encodable {
    203         var transactionId: String
    204         var includeContractTerms: Bool?
    205     }
    206 }
    207 
    208 fileprivate struct JSONTransactionById: WalletBackendFormattedRequest {
    209     typealias Response = String
    210     func operation() -> String { "getTransactionById" }
    211     func args() -> Args { Args(transactionId: transactionId, includeContractTerms: includeContractTerms) }
    212 
    213     var transactionId: String
    214     var includeContractTerms: Bool?
    215 
    216     struct Args: Encodable {
    217         var transactionId: String
    218         var includeContractTerms: Bool?
    219     }
    220 }
    221 
    222 extension WalletModel {
    223     /// get the specified transaction from Wallet-Core. No networking involved
    224     nonisolated func getTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false)
    225       async throws -> TalerTransaction {
    226         let request = GetTransactionById(transactionId: transactionId, includeContractTerms: includeContractTerms)
    227         return try await sendRequest(request, viewHandles: viewHandles)
    228     }
    229     nonisolated func jsonTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false)
    230       async throws -> String {
    231         let request = JSONTransactionById(transactionId: transactionId, includeContractTerms: includeContractTerms)
    232         return try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    233     }
    234 }
    235 // MARK: -
    236 /// The info returned from Wallet-core init
    237 struct VersionInfo: Decodable {
    238     var implementationSemver: String?
    239     var implementationGitHash: String?
    240     var version: String
    241     var exchange: String
    242     var merchant: String
    243     var bank: String
    244 }
    245 // MARK: -
    246 fileprivate struct Testing: Encodable {
    247     var denomselAllowLate: Bool
    248     var devModeActive: Bool
    249     var insecureTrustExchange: Bool
    250     var preventThrottling: Bool
    251     var skipDefaults: Bool
    252     var emitObservabilityEvents: Bool
    253     // more to come...
    254 
    255     init(devModeActive: Bool) {
    256         self.denomselAllowLate = false
    257         self.devModeActive = devModeActive
    258         self.insecureTrustExchange = false
    259         self.preventThrottling = false
    260         self.skipDefaults = false
    261         self.emitObservabilityEvents = devModeActive
    262     }
    263 }
    264 
    265 fileprivate struct Builtin: Encodable {
    266     var exchanges: [String]
    267     // more to come...
    268 }
    269 
    270 fileprivate struct Config: Encodable {
    271     var testing: Testing
    272     var builtin: Builtin
    273 }
    274 // MARK: -
    275 ///  A request to re-configure Wallet-core
    276 fileprivate struct ConfigRequest: WalletBackendFormattedRequest {
    277     var setTesting: Bool
    278 
    279     func operation() -> String { "setWalletRunConfig" }
    280     func args() -> Args {
    281         let testing = Testing(devModeActive: setTesting)
    282         let builtin = Builtin(exchanges: [])
    283         let config = Config(testing: testing, builtin: builtin)
    284         return Args(config: config)
    285     }
    286 
    287     struct Args: Encodable {
    288         var config: Config
    289     }
    290     struct Response: Decodable {
    291         var versionInfo: VersionInfo
    292     }
    293 }
    294 
    295 extension WalletModel {
    296     /// initalize Wallet-Core. Will do networking
    297     nonisolated func setConfig(setTesting: Bool) async throws -> VersionInfo {
    298         let request = ConfigRequest(setTesting: setTesting)
    299         let response = try await sendRequest(request)
    300         return response.versionInfo
    301     }
    302 }
    303 // MARK: -
    304 ///  A request to initialize Wallet-core
    305 fileprivate struct InitRequest: WalletBackendFormattedRequest {
    306     var persistentStoragePath: String
    307     var setTesting: Bool
    308 
    309     func operation() -> String { "init" }
    310     func args() -> Args {
    311         let testing = Testing(devModeActive: setTesting)
    312         let builtin = Builtin(exchanges: [])
    313         let config = Config(testing: testing, builtin: builtin)
    314         return Args(persistentStoragePath: persistentStoragePath,
    315 //                       cryptoWorkerType: "sync",
    316                                  logLevel: "info",  // trace, info, warn, error, none
    317                                    config: config,
    318                          useNativeLogging: true)
    319     }
    320 
    321     struct Args: Encodable {
    322         var persistentStoragePath: String
    323 //        var cryptoWorkerType: String
    324         var logLevel: String
    325         var config: Config
    326         var useNativeLogging: Bool
    327     }
    328     struct Response: Decodable {
    329         var versionInfo: VersionInfo
    330     }
    331 }
    332 
    333 extension WalletModel {
    334     /// initalize Wallet-Core. Might do networking
    335     nonisolated func initWalletCore(setTesting: Bool, viewHandles: Bool = false) async throws -> VersionInfo {
    336         let dbPath = try dbPath()
    337 //        logger.debug("dbPath: \(dbPath)")
    338         let request = InitRequest(persistentStoragePath: dbPath, setTesting: setTesting)
    339         let response = try await sendRequest(request, viewHandles: viewHandles)    // no Delay
    340         return response.versionInfo
    341     }
    342 
    343     private func dbUrl(_ folder: URL) -> URL {
    344         let DATABASE = "talerwalletdb-v30"
    345         let dbUrl = folder.appendingPathComponent(DATABASE, isDirectory: false)
    346                           .appendingPathExtension("sqlite3")
    347         return dbUrl
    348     }
    349 
    350     private func checkAppSupport(_ url: URL) {
    351         let fileManager = FileManager.default
    352         var resultStorage: ObjCBool = false
    353 
    354         if !fileManager.fileExists(atPath: url.path, isDirectory: &resultStorage) {
    355             do {
    356                 try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
    357                 logger.debug("created \(url.path)")
    358             } catch {
    359                 logger.error("creation failed \(error.localizedDescription)")
    360             }
    361         } else {
    362 //            logger.debug("\(url.path) exists")
    363         }
    364     }
    365 
    366     private func migrate(from source: URL, to target: URL) {
    367         let fileManager = FileManager.default
    368         let sourceUrl = dbUrl(source)
    369         let sourcePath = sourceUrl.path
    370         let targetUrl = dbUrl(target)
    371         let targetPath = targetUrl.path
    372 
    373         checkAppSupport(target)
    374         if fileManager.fileExists(atPath: sourcePath) {
    375             do {
    376                 try fileManager.moveItem(at: sourceUrl, to: targetUrl)
    377                 logger.debug("migrate: moved to \(target.path)")
    378             } catch {
    379                 logger.error("migrate: move failed \(error.localizedDescription)")
    380             }
    381         } else {
    382             logger.debug("migrate: nothing to do, no db at \(sourcePath)")
    383         }
    384 
    385         if fileManager.fileExists(atPath: targetPath) {
    386 //            logger.debug("found db at \(targetPath)")
    387         } else {
    388             logger.debug("migrate: nothing to do, no db at \(targetPath)")
    389         }
    390     }
    391 
    392     private func dbPath() throws -> String {
    393         if let docDirUrl = URL.docDirUrl {
    394             if let appSupport = URL.appSuppUrl {
    395 #if DEBUG || GNU_TALER
    396                 migrate(from: appSupport, to: docDirUrl)
    397                 return docDirUrl.path(withSlash: true)
    398 #else // TALER_WALLET or TALER_NIGHTLY
    399                 migrate(from: docDirUrl, to: appSupport)
    400                 return appSupport.path(withSlash: true)
    401 #endif
    402             } else { // should never happen
    403                 logger.error("dbPath: No applicationSupportDirectory")
    404             }
    405         } else { // should never happen
    406             logger.error("dbPath: No documentDirectory")
    407         }
    408         throw WalletBackendError.initializationError
    409     }
    410 
    411     private func cachePath() throws -> String {
    412         let fileManager = FileManager.default
    413         if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
    414             let cacheURL = cachesURL.appendingPathComponent("cache.json")
    415             let cachePath = cacheURL.path
    416             logger.debug("cachePath: \(cachePath)")
    417 
    418             if !fileManager.fileExists(atPath: cachePath) {
    419                 let contents = Data()       /// Initialize an empty `Data`.
    420                 fileManager.createFile(atPath: cachePath, contents: contents)
    421                 print("❗️ File \(cachePath) created")
    422             } else {
    423                 print("❗️ File \(cachePath) already exists")
    424             }
    425 
    426             return cachePath
    427         } else {    // should never happen
    428             logger.error("cachePath: No cachesDirectory")
    429             throw WalletBackendError.initializationError
    430         }
    431     }
    432 }
    433 // MARK: -
    434 ///  A request to reset Wallet-core to a virgin DB. WILL DESTROY ALL COINS
    435 fileprivate struct ResetRequest: WalletBackendFormattedRequest {
    436     func operation() -> String { "clearDb" }
    437     func args() -> Args { Args() }
    438 
    439     struct Args: Encodable {}                           // no arguments needed
    440     struct Response: Decodable {}
    441 }
    442 
    443 extension WalletModel {
    444     /// reset Wallet-Core
    445     nonisolated func resetWalletCore(viewHandles: Bool = false) async throws {
    446         let request = ResetRequest()
    447         _ = try await sendRequest(request, viewHandles: viewHandles)
    448     }
    449 }
    450 // MARK: -
    451 fileprivate struct ExportDbToFile: WalletBackendFormattedRequest {
    452     func operation() -> String { "exportDbToFile" }
    453     func args() -> Args { Args(directory: directory, stem: stem, forceFormat: "json") }
    454 
    455     var directory: String
    456     var stem: String
    457     struct Args: Encodable {
    458         var directory: String
    459         var stem: String
    460         var forceFormat: String
    461     }
    462     struct Response: Decodable, Sendable {              // path of the copied DB
    463         var path: String
    464     }
    465 }
    466 
    467 fileprivate struct ImportDbFromFile: WalletBackendFormattedRequest {
    468     func operation() -> String { "importDbFromFile" }
    469     func args() -> Args { Args(path: path ) }
    470 
    471     var path: String
    472     struct Args: Encodable {
    473         var path: String
    474     }
    475     struct Response: Decodable {}
    476 }
    477 
    478 fileprivate struct GetDiagnostics: WalletBackendFormattedRequest {
    479     func operation() -> String { "getDiagnostics" }
    480     func args() -> Args { Args() }
    481     struct Args: Encodable {}                           // no arguments needed
    482     typealias Response = String
    483 }
    484 
    485 fileprivate struct GetPerformanceStats: WalletBackendFormattedRequest {
    486     func operation() -> String { "testingGetPerformanceStats" }
    487     func args() -> Args { Args() }
    488     struct Args: Encodable {}                           // no arguments needed
    489     typealias Response = String
    490 }
    491 
    492 extension WalletModel {
    493     /// export, import DB, get diagnostics
    494     nonisolated func exportDbToFile(stem: String, viewHandles: Bool = false)
    495       async throws -> String? {
    496         if let docDirUrl = URL.docDirUrl {
    497             let dbPath = docDirUrl.path(withSlash: false)
    498             let request = ExportDbToFile(directory: dbPath, stem: stem)
    499     print(dbPath, stem)
    500             let response = try await sendRequest(request, viewHandles: viewHandles)
    501             return response.path
    502         } else {
    503             return nil
    504         }
    505     }
    506     nonisolated func importDbFromFile(path: String, viewHandles: Bool = false)
    507       async throws {
    508         let request = ImportDbFromFile(path: path)
    509         _ = try await sendRequest(request, viewHandles: viewHandles)
    510     }
    511     nonisolated func getDiagnostics(viewHandles: Bool = false)
    512       async throws -> String {
    513         let request = GetDiagnostics()
    514         let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    515         return response
    516     }
    517     nonisolated func getPerformanceStats(viewHandles: Bool = false)
    518     async throws -> String {
    519         let request = GetPerformanceStats()
    520         let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    521         return response
    522     }
    523 }
    524 // MARK: -
    525 fileprivate struct DevExperimentRequest: WalletBackendFormattedRequest {
    526     func operation() -> String { "applyDevExperiment" }
    527     func args() -> Args { Args(devExperimentUri: talerUri) }
    528 
    529     var talerUri: String
    530 
    531     struct Args: Encodable {
    532         var devExperimentUri: String
    533     }
    534     struct Response: Decodable {}
    535 }
    536 
    537 extension WalletModel {
    538     /// tell wallet-core to mock new transactions
    539     nonisolated func devExperimentT(talerUri: String, viewHandles: Bool = false) async throws {
    540         // T for any Thread
    541         let request = DevExperimentRequest(talerUri: talerUri)
    542         _ = try await sendRequest(request, viewHandles: viewHandles)
    543     }
    544 }