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 }