taler-ios

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

WalletModel.swift (22233B)


      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?          // deprecated
     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?                    // deprecated
     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 cancel a wallet transaction by token.
    194 fileprivate struct CancelProgressToken: WalletBackendFormattedRequest {
    195     struct Response: Decodable {}
    196     func operation() -> String { "cancelProgressToken" }
    197     func args() -> Args { Args(operation: op, progressToken: token) }
    198 
    199     var op: String
    200     var token: String
    201 
    202     struct Args: Encodable {
    203         var operation: String
    204         var progressToken: String
    205     }
    206 }
    207 /// A request to retry a wallet transaction by token.
    208 fileprivate struct RetryProgressTokenNow: WalletBackendFormattedRequest {
    209     struct Response: Decodable {}
    210     func operation() -> String { "retryProgressTokenNow" }
    211     func args() -> Args { Args(operation: op, progressToken: token) }
    212 
    213     var op: String
    214     var token: String
    215 
    216     struct Args: Encodable {
    217         var operation: String
    218         var progressToken: String
    219     }
    220 }
    221 // MARK: -
    222 /// A request to get a wallet transaction by ID.
    223 fileprivate struct GetTransactionById: WalletBackendFormattedRequest {
    224     typealias Response = TalerTransaction
    225     func operation() -> String { "getTransactionById" }
    226     func args() -> Args { Args(transactionId: txId, includeContractTerms: inclTerms) }
    227 
    228     var txId: String
    229     var inclTerms: Bool?
    230 
    231     struct Args: Encodable {
    232         var transactionId: String
    233         var includeContractTerms: Bool?
    234     }
    235 }
    236 
    237 fileprivate struct JSONTransactionById: WalletBackendFormattedRequest {
    238     typealias Response = String
    239     func operation() -> String { "getTransactionById" }
    240     func args() -> Args { Args(transactionId: transactionId, includeContractTerms: includeContractTerms) }
    241 
    242     var transactionId: String
    243     var includeContractTerms: Bool?
    244 
    245     struct Args: Encodable {
    246         var transactionId: String
    247         var includeContractTerms: Bool?
    248     }
    249 }
    250 
    251 extension WalletModel {
    252     nonisolated func cancelProgressToken(_ op: String, token: String)
    253       async throws {
    254         let request = CancelProgressToken(op: op, token: token)
    255         let _ = try await sendRequest(request)
    256     }
    257     nonisolated func retryProgressTokenNow(_ op: String, token: String)
    258     async throws {
    259         let request = RetryProgressTokenNow(op: op, token: token)
    260         let _ = try await sendRequest(request)
    261     }
    262     /// get the specified transaction from Wallet-Core. No networking involved
    263     nonisolated func getTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false)
    264       async throws -> TalerTransaction {
    265         let request = GetTransactionById(txId: transactionId, inclTerms: includeContractTerms)
    266         return try await sendRequest(request, viewHandles: viewHandles)
    267     }
    268     nonisolated func jsonTransactionById(_ transactionId: String, includeContractTerms: Bool? = nil, viewHandles: Bool = false)
    269       async throws -> String {
    270         let request = JSONTransactionById(transactionId: transactionId, includeContractTerms: includeContractTerms)
    271         return try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    272     }
    273 }
    274 // MARK: -
    275 /// The info returned from Wallet-core init
    276 struct VersionInfo: Decodable {
    277     var implementationSemver: String?
    278     var implementationGitHash: String?
    279     var version: String
    280     var exchange: String
    281     var merchant: String
    282     var bank: String
    283 }
    284 // MARK: -
    285 fileprivate struct Testing: Encodable {
    286     var denomselAllowLate: Bool
    287     var devModeActive: Bool
    288     var insecureTrustExchange: Bool
    289     var preventThrottling: Bool
    290     var skipDefaults: Bool
    291     var emitObservabilityEvents: Bool
    292     // more to come...
    293 
    294     init(devModeActive: Bool) {
    295         self.denomselAllowLate = false
    296         self.devModeActive = devModeActive
    297         self.insecureTrustExchange = false
    298         self.preventThrottling = false
    299         self.skipDefaults = false
    300         self.emitObservabilityEvents = devModeActive
    301     }
    302 }
    303 
    304 fileprivate struct Builtin: Encodable {
    305     var exchanges: [String]
    306     // more to come...
    307 }
    308 
    309 fileprivate struct Config: Encodable {
    310     var testing: Testing
    311     var builtin: Builtin
    312 }
    313 // MARK: -
    314 ///  A request to re-configure Wallet-core
    315 fileprivate struct ConfigRequest: WalletBackendFormattedRequest {
    316     var setTesting: Bool
    317 
    318     func operation() -> String { "setWalletRunConfig" }
    319     func args() -> Args {
    320         let testing = Testing(devModeActive: setTesting)
    321         let builtin = Builtin(exchanges: [])
    322         let config = Config(testing: testing, builtin: builtin)
    323         return Args(config: config)
    324     }
    325 
    326     struct Args: Encodable {
    327         var config: Config
    328     }
    329     struct Response: Decodable {
    330         var versionInfo: VersionInfo
    331     }
    332 }
    333 
    334 extension WalletModel {
    335     /// initalize Wallet-Core. Will do networking
    336     @discardableResult
    337     nonisolated func setConfig(setTesting: Bool) async throws -> VersionInfo {
    338         let request = ConfigRequest(setTesting: setTesting)
    339         let response = try await sendRequest(request)
    340         return response.versionInfo
    341     }
    342 }
    343 // MARK: -
    344 ///  A request to initialize Wallet-core
    345 fileprivate struct InitRequest: WalletBackendFormattedRequest {
    346     var persistentStoragePath: String
    347     var setTesting: Bool
    348 
    349     func operation() -> String { "init" }
    350     func args() -> Args {
    351         let testing = Testing(devModeActive: setTesting)
    352         let builtin = Builtin(exchanges: [])
    353         let config = Config(testing: testing, builtin: builtin)
    354         return Args(persistentStoragePath: persistentStoragePath,
    355 //                       cryptoWorkerType: "sync",
    356                                  logLevel: "info",  // trace, info, warn, error, none
    357                                    config: config,
    358                          useNativeLogging: true)
    359     }
    360 
    361     struct Args: Encodable {
    362         var persistentStoragePath: String
    363 //        var cryptoWorkerType: String
    364         var logLevel: String
    365         var config: Config
    366         var useNativeLogging: Bool
    367     }
    368     struct Response: Decodable {
    369         var versionInfo: VersionInfo
    370     }
    371 }
    372 
    373 extension WalletModel {
    374     /// initalize Wallet-Core. Might do networking
    375     nonisolated func initWalletCore(setTesting: Bool, viewHandles: Bool = false) async throws -> VersionInfo {
    376         let dbPath = try dbPath()
    377 //        logger.debug("dbPath: \(dbPath)")
    378         let request = InitRequest(persistentStoragePath: dbPath, setTesting: setTesting)
    379         let response = try await sendRequest(request, viewHandles: viewHandles)    // no Delay
    380         return response.versionInfo
    381     }
    382 
    383     private func dbUrl(_ folder: URL) -> URL {
    384         let DATABASE = "talerwalletdb-v30"
    385         let dbUrl = folder.appendingPathComponent(DATABASE, isDirectory: false)
    386                           .appendingPathExtension("sqlite3")
    387         return dbUrl
    388     }
    389 
    390     private func checkAppSupport(_ url: URL) {
    391         let fileManager = FileManager.default
    392         var resultStorage: ObjCBool = false
    393 
    394         if !fileManager.fileExists(atPath: url.path, isDirectory: &resultStorage) {
    395             do {
    396                 try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
    397                 logger.debug("created \(url.path)")
    398             } catch {
    399                 logger.error("creation failed \(error.localizedDescription)")
    400             }
    401         } else {
    402 //            logger.debug("\(url.path) exists")
    403         }
    404     }
    405 
    406     private func migrate(from source: URL, to target: URL) {
    407         let fileManager = FileManager.default
    408         let sourceUrl = dbUrl(source)
    409         let sourcePath = sourceUrl.path
    410         let targetUrl = dbUrl(target)
    411         let targetPath = targetUrl.path
    412 
    413         checkAppSupport(target)
    414         if fileManager.fileExists(atPath: sourcePath) {
    415             do {
    416                 try fileManager.moveItem(at: sourceUrl, to: targetUrl)
    417                 logger.debug("migrate: moved to \(target.path)")
    418             } catch {
    419                 logger.error("migrate: move failed \(error.localizedDescription)")
    420             }
    421 //        } else {
    422 //            logger.debug("migrate: nothing to do, no db at \(sourcePath)")
    423         }
    424 
    425 //        if fileManager.fileExists(atPath: targetPath) {
    426 //            logger.debug("found db at \(targetPath)")
    427 //        } else {
    428 //            logger.debug("migrate: nothing to do, no db at \(targetPath)")
    429 //        }
    430     }
    431 
    432     private func dbPath() throws -> String {
    433         if let docDirUrl = URL.docDirUrl {
    434             if let appSupport = URL.appSuppUrl {
    435 #if DEBUG || GNU_TALER
    436                 migrate(from: appSupport, to: docDirUrl)
    437                 return docDirUrl.path(withSlash: true)
    438 #else // TALER_WALLET or TALER_NIGHTLY
    439                 migrate(from: docDirUrl, to: appSupport)
    440                 return appSupport.path(withSlash: true)
    441 #endif
    442             } else { // should never happen
    443                 logger.error("dbPath: No applicationSupportDirectory")
    444             }
    445         } else { // should never happen
    446             logger.error("dbPath: No documentDirectory")
    447         }
    448         throw WalletBackendError.initializationError
    449     }
    450 
    451     private func cachePath() throws -> String {
    452         let fileManager = FileManager.default
    453         if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
    454             let cacheURL = cachesURL.appendingPathComponent("cache.json")
    455             let cachePath = cacheURL.path
    456             logger.debug("cachePath: \(cachePath)")
    457 
    458             if !fileManager.fileExists(atPath: cachePath) {
    459                 let contents = Data()       /// Initialize an empty `Data`.
    460                 fileManager.createFile(atPath: cachePath, contents: contents)
    461                 print("❗️ File \(cachePath) created")
    462             } else {
    463                 print("❗️ File \(cachePath) already exists")
    464             }
    465 
    466             return cachePath
    467         } else {    // should never happen
    468             logger.error("cachePath: No cachesDirectory")
    469             throw WalletBackendError.initializationError
    470         }
    471     }
    472 }
    473 // MARK: -
    474 ///  A request to reset Wallet-core to a virgin DB. WILL DESTROY ALL COINS
    475 fileprivate struct ResetRequest: WalletBackendFormattedRequest {
    476     func operation() -> String { "clearDb" }
    477     func args() -> Args { Args() }
    478 
    479     struct Args: Encodable {}                           // no arguments needed
    480     struct Response: Decodable {}
    481 }
    482 
    483 extension WalletModel {
    484     /// reset Wallet-Core
    485     nonisolated func resetWalletCore(viewHandles: Bool = false) async throws {
    486         let request = ResetRequest()
    487         _ = try await sendRequest(request, viewHandles: viewHandles)
    488     }
    489 }
    490 // MARK: -
    491 fileprivate struct ExportDbToFile: WalletBackendFormattedRequest {
    492     func operation() -> String { "exportDbToFile" }
    493     func args() -> Args { Args(directory: directory, stem: stem, forceFormat: "json") }
    494 
    495     var directory: String
    496     var stem: String
    497     struct Args: Encodable {
    498         var directory: String
    499         var stem: String
    500         var forceFormat: String
    501     }
    502     struct Response: Decodable, Sendable {              // path of the copied DB
    503         var path: String
    504     }
    505 }
    506 
    507 fileprivate struct ImportDbFromFile: WalletBackendFormattedRequest {
    508     func operation() -> String { "importDbFromFile" }
    509     func args() -> Args { Args(path: path ) }
    510 
    511     var path: String
    512     struct Args: Encodable {
    513         var path: String
    514     }
    515     struct Response: Decodable {}
    516 }
    517 
    518 fileprivate struct GetDiagnostics: WalletBackendFormattedRequest {
    519     func operation() -> String { "getDiagnostics" }
    520     func args() -> Args { Args() }
    521     struct Args: Encodable {}                           // no arguments needed
    522     typealias Response = String
    523 }
    524 
    525 fileprivate struct GetPerformanceStats: WalletBackendFormattedRequest {
    526     func operation() -> String { "testingGetPerformanceStats" }
    527     func args() -> Args { Args() }
    528     struct Args: Encodable {}                           // no arguments needed
    529     typealias Response = String
    530 }
    531 
    532 extension WalletModel {
    533     /// export, import DB, get diagnostics
    534     nonisolated func exportDbToFile(stem: String, viewHandles: Bool = false)
    535       async throws -> String? {
    536         if let docDirUrl = URL.docDirUrl {
    537             let dbPath = docDirUrl.path(withSlash: false)
    538             let request = ExportDbToFile(directory: dbPath, stem: stem)
    539     print(dbPath, stem)
    540             let response = try await sendRequest(request, viewHandles: viewHandles)
    541             return response.path
    542         } else {
    543             return nil
    544         }
    545     }
    546     nonisolated func importDbFromFile(path: String, viewHandles: Bool = false)
    547       async throws {
    548         let request = ImportDbFromFile(path: path)
    549         _ = try await sendRequest(request, viewHandles: viewHandles)
    550     }
    551     nonisolated func getDiagnostics(viewHandles: Bool = false)
    552       async throws -> String {
    553         let request = GetDiagnostics()
    554         let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    555         return response
    556     }
    557     nonisolated func getPerformanceStats(viewHandles: Bool = false)
    558     async throws -> String {
    559         let request = GetPerformanceStats()
    560         let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true)
    561         return response
    562     }
    563 }
    564 // MARK: -
    565 fileprivate struct DevExperimentRequest: WalletBackendFormattedRequest {
    566     func operation() -> String { "applyDevExperiment" }
    567     func args() -> Args { Args(devExperimentUri: talerUri) }
    568 
    569     var talerUri: String
    570 
    571     struct Args: Encodable {
    572         var devExperimentUri: String
    573     }
    574     struct Response: Decodable {}
    575 }
    576 
    577 extension WalletModel {
    578     /// tell wallet-core to mock new transactions
    579     nonisolated func devExperimentT(_ talerUri: String, viewHandles: Bool = false) async throws {
    580         // T for any Thread
    581         let request = DevExperimentRequest(talerUri: talerUri)
    582         _ = try await sendRequest(request, viewHandles: viewHandles)
    583     }
    584 }