WalletModel.swift (20886B)
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 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 nonisolated func setConfig(setTesting: Bool) async throws -> VersionInfo { 298 let request = ConfigRequest(setTesting: setTesting) 299 let response = try await sendRequest(request) 300 return response.versionInfo 301 } 302 } 303 // MARK: - 304 /// A request to initialize Wallet-core 305 fileprivate struct InitRequest: WalletBackendFormattedRequest { 306 var persistentStoragePath: String 307 var setTesting: Bool 308 309 func operation() -> String { "init" } 310 func args() -> Args { 311 let testing = Testing(devModeActive: setTesting) 312 let builtin = Builtin(exchanges: []) 313 let config = Config(testing: testing, builtin: builtin) 314 return Args(persistentStoragePath: persistentStoragePath, 315 // cryptoWorkerType: "sync", 316 logLevel: "info", // trace, info, warn, error, none 317 config: config, 318 useNativeLogging: true) 319 } 320 321 struct Args: Encodable { 322 var persistentStoragePath: String 323 // var cryptoWorkerType: String 324 var logLevel: String 325 var config: Config 326 var useNativeLogging: Bool 327 } 328 struct Response: Decodable { 329 var versionInfo: VersionInfo 330 } 331 } 332 333 extension WalletModel { 334 /// initalize Wallet-Core. Might do networking 335 nonisolated func initWalletCore(setTesting: Bool, viewHandles: Bool = false) async throws -> VersionInfo { 336 let dbPath = try dbPath() 337 // logger.debug("dbPath: \(dbPath)") 338 let request = InitRequest(persistentStoragePath: dbPath, setTesting: setTesting) 339 let response = try await sendRequest(request, viewHandles: viewHandles) // no Delay 340 return response.versionInfo 341 } 342 343 private func dbUrl(_ folder: URL) -> URL { 344 let DATABASE = "talerwalletdb-v30" 345 let dbUrl = folder.appendingPathComponent(DATABASE, isDirectory: false) 346 .appendingPathExtension("sqlite3") 347 return dbUrl 348 } 349 350 private func checkAppSupport(_ url: URL) { 351 let fileManager = FileManager.default 352 var resultStorage: ObjCBool = false 353 354 if !fileManager.fileExists(atPath: url.path, isDirectory: &resultStorage) { 355 do { 356 try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) 357 logger.debug("created \(url.path)") 358 } catch { 359 logger.error("creation failed \(error.localizedDescription)") 360 } 361 } else { 362 // logger.debug("\(url.path) exists") 363 } 364 } 365 366 private func migrate(from source: URL, to target: URL) { 367 let fileManager = FileManager.default 368 let sourceUrl = dbUrl(source) 369 let sourcePath = sourceUrl.path 370 let targetUrl = dbUrl(target) 371 let targetPath = targetUrl.path 372 373 checkAppSupport(target) 374 if fileManager.fileExists(atPath: sourcePath) { 375 do { 376 try fileManager.moveItem(at: sourceUrl, to: targetUrl) 377 logger.debug("migrate: moved to \(target.path)") 378 } catch { 379 logger.error("migrate: move failed \(error.localizedDescription)") 380 } 381 } else { 382 logger.debug("migrate: nothing to do, no db at \(sourcePath)") 383 } 384 385 if fileManager.fileExists(atPath: targetPath) { 386 // logger.debug("found db at \(targetPath)") 387 } else { 388 logger.debug("migrate: nothing to do, no db at \(targetPath)") 389 } 390 } 391 392 private func dbPath() throws -> String { 393 if let docDirUrl = URL.docDirUrl { 394 if let appSupport = URL.appSuppUrl { 395 #if DEBUG || GNU_TALER 396 migrate(from: appSupport, to: docDirUrl) 397 return docDirUrl.path(withSlash: true) 398 #else // TALER_WALLET or TALER_NIGHTLY 399 migrate(from: docDirUrl, to: appSupport) 400 return appSupport.path(withSlash: true) 401 #endif 402 } else { // should never happen 403 logger.error("dbPath: No applicationSupportDirectory") 404 } 405 } else { // should never happen 406 logger.error("dbPath: No documentDirectory") 407 } 408 throw WalletBackendError.initializationError 409 } 410 411 private func cachePath() throws -> String { 412 let fileManager = FileManager.default 413 if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first { 414 let cacheURL = cachesURL.appendingPathComponent("cache.json") 415 let cachePath = cacheURL.path 416 logger.debug("cachePath: \(cachePath)") 417 418 if !fileManager.fileExists(atPath: cachePath) { 419 let contents = Data() /// Initialize an empty `Data`. 420 fileManager.createFile(atPath: cachePath, contents: contents) 421 print("❗️ File \(cachePath) created") 422 } else { 423 print("❗️ File \(cachePath) already exists") 424 } 425 426 return cachePath 427 } else { // should never happen 428 logger.error("cachePath: No cachesDirectory") 429 throw WalletBackendError.initializationError 430 } 431 } 432 } 433 // MARK: - 434 /// A request to reset Wallet-core to a virgin DB. WILL DESTROY ALL COINS 435 fileprivate struct ResetRequest: WalletBackendFormattedRequest { 436 func operation() -> String { "clearDb" } 437 func args() -> Args { Args() } 438 439 struct Args: Encodable {} // no arguments needed 440 struct Response: Decodable {} 441 } 442 443 extension WalletModel { 444 /// reset Wallet-Core 445 nonisolated func resetWalletCore(viewHandles: Bool = false) async throws { 446 let request = ResetRequest() 447 _ = try await sendRequest(request, viewHandles: viewHandles) 448 } 449 } 450 // MARK: - 451 fileprivate struct ExportDbToFile: WalletBackendFormattedRequest { 452 func operation() -> String { "exportDbToFile" } 453 func args() -> Args { Args(directory: directory, stem: stem, forceFormat: "json") } 454 455 var directory: String 456 var stem: String 457 struct Args: Encodable { 458 var directory: String 459 var stem: String 460 var forceFormat: String 461 } 462 struct Response: Decodable, Sendable { // path of the copied DB 463 var path: String 464 } 465 } 466 467 fileprivate struct ImportDbFromFile: WalletBackendFormattedRequest { 468 func operation() -> String { "importDbFromFile" } 469 func args() -> Args { Args(path: path ) } 470 471 var path: String 472 struct Args: Encodable { 473 var path: String 474 } 475 struct Response: Decodable {} 476 } 477 478 fileprivate struct GetDiagnostics: WalletBackendFormattedRequest { 479 func operation() -> String { "getDiagnostics" } 480 func args() -> Args { Args() } 481 struct Args: Encodable {} // no arguments needed 482 typealias Response = String 483 } 484 485 fileprivate struct GetPerformanceStats: WalletBackendFormattedRequest { 486 func operation() -> String { "testingGetPerformanceStats" } 487 func args() -> Args { Args() } 488 struct Args: Encodable {} // no arguments needed 489 typealias Response = String 490 } 491 492 extension WalletModel { 493 /// export, import DB, get diagnostics 494 nonisolated func exportDbToFile(stem: String, viewHandles: Bool = false) 495 async throws -> String? { 496 if let docDirUrl = URL.docDirUrl { 497 let dbPath = docDirUrl.path(withSlash: false) 498 let request = ExportDbToFile(directory: dbPath, stem: stem) 499 print(dbPath, stem) 500 let response = try await sendRequest(request, viewHandles: viewHandles) 501 return response.path 502 } else { 503 return nil 504 } 505 } 506 nonisolated func importDbFromFile(path: String, viewHandles: Bool = false) 507 async throws { 508 let request = ImportDbFromFile(path: path) 509 _ = try await sendRequest(request, viewHandles: viewHandles) 510 } 511 nonisolated func getDiagnostics(viewHandles: Bool = false) 512 async throws -> String { 513 let request = GetDiagnostics() 514 let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true) 515 return response 516 } 517 nonisolated func getPerformanceStats(viewHandles: Bool = false) 518 async throws -> String { 519 let request = GetPerformanceStats() 520 let response = try await sendRequest(request, viewHandles: viewHandles, asJSON: true) 521 return response 522 } 523 } 524 // MARK: - 525 fileprivate struct DevExperimentRequest: WalletBackendFormattedRequest { 526 func operation() -> String { "applyDevExperiment" } 527 func args() -> Args { Args(devExperimentUri: talerUri) } 528 529 var talerUri: String 530 531 struct Args: Encodable { 532 var devExperimentUri: String 533 } 534 struct Response: Decodable {} 535 } 536 537 extension WalletModel { 538 /// tell wallet-core to mock new transactions 539 nonisolated func devExperimentT(talerUri: String, viewHandles: Bool = false) async throws { 540 // T for any Thread 541 let request = DevExperimentRequest(talerUri: talerUri) 542 _ = try await sendRequest(request, viewHandles: viewHandles) 543 } 544 }