taler-ios

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

WalletModel.swift (19822B)


      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     let semaphore = AsyncSemaphore(value: 1)
    117 
    118     @Published var error2: ErrorData? = nil
    119 
    120     @MainActor func setError(_ theError: Error?) {
    121         if let theError {
    122             self.error2 = .error(theError)
    123         } else {
    124             self.error2 = nil
    125         }
    126     }
    127     @MainActor func setMessage(_ title: String,_ theMessage: String?) {
    128         if let theMessage {
    129             self.error2 = .message(title: title, message: theMessage)
    130         } else {
    131             self.error2 = nil
    132         }
    133     }
    134 
    135     func sendRequest<T: WalletBackendFormattedRequest> (_ request: T, viewHandles: Bool = false, asJSON: Bool = false)
    136       async throws -> T.Response {    // T for any Thread
    137 #if !DEBUG
    138         logger.log("sending: \(request.operation(), privacy: .public)")
    139 #endif
    140         let sendTime = Date.now
    141         do {
    142             let (response, id) = try await WalletCore.shared.sendFormattedRequest(request, asJSON: asJSON)
    143 #if !DEBUG
    144             let timeUsed = Date.now - sendTime
    145             logger.log("received: \(request.operation(), privacy: .public) (\(id, privacy: .public)) after \(timeUsed.milliseconds, privacy: .public) ms")
    146 #endif
    147             return response
    148         } catch {       // rethrows
    149             let timeUsed = Date.now - sendTime
    150             logger.error("\(request.operation(), privacy: .public) failed after \(timeUsed.milliseconds, privacy: .public) ms\n\(error, privacy: .public)")
    151             if !viewHandles {
    152                 // TODO: symlog + controller sound
    153                 await setError(error)
    154             }
    155             throw error
    156         }
    157     }
    158 }
    159 // MARK: -
    160 /// A request to tell wallet-core about the network.
    161 fileprivate struct ApplicationResumedRequest: WalletBackendFormattedRequest {
    162     struct Response: Decodable {}
    163     func operation() -> String { "hintApplicationResumed" }
    164     func args() -> Args { Args() }
    165 
    166     struct Args: Encodable {}                           // no arguments needed
    167 }
    168 
    169 fileprivate struct NetworkAvailabilityRequest: WalletBackendFormattedRequest {
    170     struct Response: Decodable {}
    171     func operation() -> String { "hintNetworkAvailability" }
    172     func args() -> Args { Args(isNetworkAvailable: isNetworkAvailable) }
    173 
    174     var isNetworkAvailable: Bool
    175 
    176     struct Args: Encodable {
    177         var isNetworkAvailable: Bool
    178     }
    179 }
    180 
    181 extension WalletModel {
    182     func hintNetworkAvailabilityT(_ isNetworkAvailable: Bool = false) async {
    183         // T for any Thread
    184         let request = NetworkAvailabilityRequest(isNetworkAvailable: isNetworkAvailable)
    185         _ = try? await sendRequest(request)
    186     }
    187     func hintApplicationResumedT() async {
    188         // T for any Thread
    189         let request = ApplicationResumedRequest()
    190         _ = try? await sendRequest(request)
    191     }
    192 }
    193 // MARK: -
    194 /// A request to get a wallet transaction by ID.
    195 fileprivate struct GetTransactionById: WalletBackendFormattedRequest {
    196     typealias Response = TalerTransaction
    197     func operation() -> String { "getTransactionById" }
    198     func args() -> Args { Args(transactionId: transactionId, includeContractTerms: includeContractTerms) }
    199 
    200     var transactionId: String
    201     var includeContractTerms: Bool?
    202 
    203     struct Args: Encodable {
    204         var transactionId: String
    205         var includeContractTerms: Bool?
    206     }
    207 }
    208 
    209 fileprivate struct JSONTransactionById: WalletBackendFormattedRequest {
    210     typealias Response = String
    211     func operation() -> String { "getTransactionById" }
    212     func args() -> Args { Args(transactionId: transactionId, includeContractTerms: includeContractTerms) }
    213 
    214     var transactionId: String
    215     var includeContractTerms: Bool?
    216 
    217     struct Args: Encodable {
    218         var transactionId: String
    219         var includeContractTerms: Bool?
    220     }
    221 }
    222 
    223 extension WalletModel {
    224     /// get the specified transaction from Wallet-Core. No networking involved
    225     nonisolated func getTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false)
    226       async throws -> TalerTransaction {
    227         let request = GetTransactionById(transactionId: transactionId, includeContractTerms: includeContractTerms)
    228         return try await sendRequest(request, viewHandles: viewHandles)
    229     }
    230     nonisolated func jsonTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false)
    231       async throws -> String {
    232         let request = JSONTransactionById(transactionId: transactionId, includeContractTerms: includeContractTerms)
    233         return try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    234     }
    235 }
    236 // MARK: -
    237 /// The info returned from Wallet-core init
    238 struct VersionInfo: Decodable {
    239     var implementationSemver: String?
    240     var implementationGitHash: String?
    241     var version: String
    242     var exchange: String
    243     var merchant: String
    244     var bank: String
    245 }
    246 // MARK: -
    247 fileprivate struct Testing: Encodable {
    248     var denomselAllowLate: Bool
    249     var devModeActive: Bool
    250     var insecureTrustExchange: Bool
    251     var preventThrottling: Bool
    252     var skipDefaults: Bool
    253     var emitObservabilityEvents: Bool
    254     // more to come...
    255 
    256     init(devModeActive: Bool) {
    257         self.denomselAllowLate = false
    258         self.devModeActive = devModeActive
    259         self.insecureTrustExchange = false
    260         self.preventThrottling = false
    261         self.skipDefaults = false
    262         self.emitObservabilityEvents = true
    263     }
    264 }
    265 
    266 fileprivate struct Builtin: Encodable {
    267     var exchanges: [String]
    268     // more to come...
    269 }
    270 
    271 fileprivate struct Config: Encodable {
    272     var testing: Testing
    273     var builtin: Builtin
    274 }
    275 // MARK: -
    276 ///  A request to re-configure Wallet-core
    277 fileprivate struct ConfigRequest: WalletBackendFormattedRequest {
    278     var setTesting: Bool
    279 
    280     func operation() -> String { "setWalletRunConfig" }
    281     func args() -> Args {
    282         let testing = Testing(devModeActive: setTesting)
    283         let builtin = Builtin(exchanges: [])
    284         let config = Config(testing: testing, builtin: builtin)
    285         return Args(config: config)
    286     }
    287 
    288     struct Args: Encodable {
    289         var config: Config
    290     }
    291     struct Response: Decodable {
    292         var versionInfo: VersionInfo
    293     }
    294 }
    295 
    296 extension WalletModel {
    297     /// initalize Wallet-Core. Will do networking
    298     nonisolated func setConfig(setTesting: Bool) async throws -> VersionInfo {
    299         let request = ConfigRequest(setTesting: setTesting)
    300         let response = try await sendRequest(request)
    301         return response.versionInfo
    302     }
    303 }
    304 // MARK: -
    305 ///  A request to initialize Wallet-core
    306 fileprivate struct InitRequest: WalletBackendFormattedRequest {
    307     var persistentStoragePath: String
    308     var setTesting: Bool
    309 
    310     func operation() -> String { "init" }
    311     func args() -> Args {
    312         let testing = Testing(devModeActive: setTesting)
    313         let builtin = Builtin(exchanges: [])
    314         let config = Config(testing: testing, builtin: builtin)
    315         return Args(persistentStoragePath: persistentStoragePath,
    316 //                       cryptoWorkerType: "sync",
    317                                  logLevel: "info",  // trace, info, warn, error, none
    318                                    config: config,
    319                          useNativeLogging: true)
    320     }
    321 
    322     struct Args: Encodable {
    323         var persistentStoragePath: String
    324 //        var cryptoWorkerType: String
    325         var logLevel: String
    326         var config: Config
    327         var useNativeLogging: Bool
    328     }
    329     struct Response: Decodable {
    330         var versionInfo: VersionInfo
    331     }
    332 }
    333 
    334 extension WalletModel {
    335     /// initalize Wallet-Core. Might do networking
    336     nonisolated func initWalletCore(setTesting: Bool, viewHandles: Bool = false) async throws -> VersionInfo {
    337         let dbPath = try dbPath()
    338 //        logger.debug("dbPath: \(dbPath)")
    339         let request = InitRequest(persistentStoragePath: dbPath, setTesting: setTesting)
    340         let response = try await sendRequest(request, viewHandles: viewHandles)    // no Delay
    341         return response.versionInfo
    342     }
    343 
    344     private func dbUrl(_ folder: URL) -> URL {
    345         let DATABASE = "talerwalletdb-v30"
    346         let dbUrl = folder.appendingPathComponent(DATABASE, isDirectory: false)
    347                           .appendingPathExtension("sqlite3")
    348         return dbUrl
    349     }
    350 
    351     private func checkAppSupport(_ url: URL) {
    352         let fileManager = FileManager.default
    353         var resultStorage: ObjCBool = false
    354 
    355         if !fileManager.fileExists(atPath: url.path, isDirectory: &resultStorage) {
    356             do {
    357                 try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
    358                 logger.debug("created \(url.path)")
    359             } catch {
    360                 logger.error("creation failed \(error.localizedDescription)")
    361             }
    362         } else {
    363 //            logger.debug("\(url.path) exists")
    364         }
    365     }
    366 
    367     private func migrate(from source: URL, to target: URL) {
    368         let fileManager = FileManager.default
    369         let sourceUrl = dbUrl(source)
    370         let sourcePath = sourceUrl.path
    371         let targetUrl = dbUrl(target)
    372         let targetPath = targetUrl.path
    373 
    374         checkAppSupport(target)
    375         if fileManager.fileExists(atPath: sourcePath) {
    376             do {
    377                 try fileManager.moveItem(at: sourceUrl, to: targetUrl)
    378                 logger.debug("migrate: moved to \(target.path)")
    379             } catch {
    380                 logger.error("migrate: move failed \(error.localizedDescription)")
    381             }
    382         } else {
    383             logger.debug("migrate: nothing to do, no db at \(sourcePath)")
    384         }
    385 
    386         if fileManager.fileExists(atPath: targetPath) {
    387 //            logger.debug("found db at \(targetPath)")
    388         } else {
    389             logger.debug("migrate: nothing to do, no db at \(targetPath)")
    390         }
    391     }
    392 
    393     private func dbPath() throws -> String {
    394         if let docDirUrl = URL.docDirUrl {
    395             if let appSupport = URL.appSuppUrl {
    396 #if DEBUG || GNU_TALER
    397                 migrate(from: appSupport, to: docDirUrl)
    398                 return docDirUrl.path(withSlash: true)
    399 #else // TALER_WALLET or TALER_NIGHTLY
    400                 migrate(from: docDirUrl, to: appSupport)
    401                 return appSupport.path(withSlash: true)
    402 #endif
    403             } else { // should never happen
    404                 logger.error("dbPath: No applicationSupportDirectory")
    405             }
    406         } else { // should never happen
    407             logger.error("dbPath: No documentDirectory")
    408         }
    409         throw WalletBackendError.initializationError
    410     }
    411 
    412     private func cachePath() throws -> String {
    413         let fileManager = FileManager.default
    414         if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
    415             let cacheURL = cachesURL.appendingPathComponent("cache.json")
    416             let cachePath = cacheURL.path
    417             logger.debug("cachePath: \(cachePath)")
    418 
    419             if !fileManager.fileExists(atPath: cachePath) {
    420                 let contents = Data()       /// Initialize an empty `Data`.
    421                 fileManager.createFile(atPath: cachePath, contents: contents)
    422                 print("❗️ File \(cachePath) created")
    423             } else {
    424                 print("❗️ File \(cachePath) already exists")
    425             }
    426 
    427             return cachePath
    428         } else {    // should never happen
    429             logger.error("cachePath: No cachesDirectory")
    430             throw WalletBackendError.initializationError
    431         }
    432     }
    433 }
    434 // MARK: -
    435 ///  A request to reset Wallet-core to a virgin DB. WILL DESTROY ALL COINS
    436 fileprivate struct ResetRequest: WalletBackendFormattedRequest {
    437     func operation() -> String { "clearDb" }
    438     func args() -> Args { Args() }
    439 
    440     struct Args: Encodable {}                           // no arguments needed
    441     struct Response: Decodable {}
    442 }
    443 
    444 extension WalletModel {
    445     /// reset Wallet-Core
    446     nonisolated func resetWalletCore(viewHandles: Bool = false) async throws {
    447         let request = ResetRequest()
    448         _ = try await sendRequest(request, viewHandles: viewHandles)
    449     }
    450 }
    451 // MARK: -
    452 fileprivate struct ExportDbToFile: WalletBackendFormattedRequest {
    453     func operation() -> String { "exportDbToFile" }
    454     func args() -> Args { Args(directory: directory, stem: stem, forceFormat: "json") }
    455 
    456     var directory: String
    457     var stem: String
    458     struct Args: Encodable {
    459         var directory: String
    460         var stem: String
    461         var forceFormat: String
    462     }
    463     struct Response: Decodable, Sendable {              // path of the copied DB
    464         var path: String
    465     }
    466 }
    467 
    468 fileprivate struct ImportDbFromFile: WalletBackendFormattedRequest {
    469     func operation() -> String { "importDbFromFile" }
    470     func args() -> Args { Args(path: path ) }
    471 
    472     var path: String
    473     struct Args: Encodable {
    474         var path: String
    475     }
    476     struct Response: Decodable {}
    477 }
    478 
    479 extension WalletModel {
    480     /// export DB
    481     nonisolated func exportDbToFile(stem: String, viewHandles: Bool = false)
    482       async throws -> String? {
    483         if let docDirUrl = URL.docDirUrl {
    484             let dbPath = docDirUrl.path(withSlash: false)
    485             let request = ExportDbToFile(directory: dbPath, stem: stem)
    486     print(dbPath, stem)
    487             let response = try await sendRequest(request, viewHandles: viewHandles)
    488             return response.path
    489         } else {
    490             return nil
    491         }
    492     }
    493     nonisolated func importDbFromFile(path: String, viewHandles: Bool = false)
    494       async throws {
    495         let request = ImportDbFromFile(path: path)
    496         _ = try await sendRequest(request, viewHandles: viewHandles)
    497     }
    498 }
    499 // MARK: -
    500 fileprivate struct DevExperimentRequest: WalletBackendFormattedRequest {
    501     func operation() -> String { "applyDevExperiment" }
    502     func args() -> Args { Args(devExperimentUri: talerUri) }
    503 
    504     var talerUri: String
    505 
    506     struct Args: Encodable {
    507         var devExperimentUri: String
    508     }
    509     struct Response: Decodable {}
    510 }
    511 
    512 extension WalletModel {
    513     /// tell wallet-core to mock new transactions
    514     nonisolated func devExperimentT(talerUri: String, viewHandles: Bool = false) async throws {
    515         // T for any Thread
    516         let request = DevExperimentRequest(talerUri: talerUri)
    517         _ = try await sendRequest(request, viewHandles: viewHandles)
    518     }
    519 }