taler-ios

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

WalletModel.swift (20925B)


      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 final class WalletModel: ObservableObject, Sendable {
    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     @discardableResult
    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 fileprivate struct GetDiagnostics: WalletBackendFormattedRequest {
    480     func operation() -> String { "getDiagnostics" }
    481     func args() -> Args { Args() }
    482     struct Args: Encodable {}                           // no arguments needed
    483     typealias Response = String
    484 }
    485 
    486 fileprivate struct GetPerformanceStats: WalletBackendFormattedRequest {
    487     func operation() -> String { "testingGetPerformanceStats" }
    488     func args() -> Args { Args() }
    489     struct Args: Encodable {}                           // no arguments needed
    490     typealias Response = String
    491 }
    492 
    493 extension WalletModel {
    494     /// export, import DB, get diagnostics
    495     nonisolated func exportDbToFile(stem: String, viewHandles: Bool = false)
    496       async throws -> String? {
    497         if let docDirUrl = URL.docDirUrl {
    498             let dbPath = docDirUrl.path(withSlash: false)
    499             let request = ExportDbToFile(directory: dbPath, stem: stem)
    500     print(dbPath, stem)
    501             let response = try await sendRequest(request, viewHandles: viewHandles)
    502             return response.path
    503         } else {
    504             return nil
    505         }
    506     }
    507     nonisolated func importDbFromFile(path: String, viewHandles: Bool = false)
    508       async throws {
    509         let request = ImportDbFromFile(path: path)
    510         _ = try await sendRequest(request, viewHandles: viewHandles)
    511     }
    512     nonisolated func getDiagnostics(viewHandles: Bool = false)
    513       async throws -> String {
    514         let request = GetDiagnostics()
    515         let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    516         return response
    517     }
    518     nonisolated func getPerformanceStats(viewHandles: Bool = false)
    519     async throws -> String {
    520         let request = GetPerformanceStats()
    521         let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    522         return response
    523     }
    524 }
    525 // MARK: -
    526 fileprivate struct DevExperimentRequest: WalletBackendFormattedRequest {
    527     func operation() -> String { "applyDevExperiment" }
    528     func args() -> Args { Args(devExperimentUri: talerUri) }
    529 
    530     var talerUri: String
    531 
    532     struct Args: Encodable {
    533         var devExperimentUri: String
    534     }
    535     struct Response: Decodable {}
    536 }
    537 
    538 extension WalletModel {
    539     /// tell wallet-core to mock new transactions
    540     nonisolated func devExperimentT(talerUri: String, viewHandles: Bool = false) async throws {
    541         // T for any Thread
    542         let request = DevExperimentRequest(talerUri: talerUri)
    543         _ = try await sendRequest(request, viewHandles: viewHandles)
    544     }
    545 }