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 }