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 }