Model+Exchange.swift (17726B)
1 /* 2 * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * Model+Exchange 7 * 8 * @author Marc Stibane 9 */ 10 import Foundation 11 import taler_swift 12 import SymLog 13 14 struct OperationErrorInfo: Codable, Hashable { 15 var error: TalerErrorDetail 16 } 17 18 enum ExchangeType: String, Codable { 19 case demo 20 case prod 21 } 22 23 enum ExchangeEntryStatus: String, Codable { 24 case preset 25 case ephemeral 26 case used 27 } 28 29 enum ExchangeUpdateStatus: String, Codable { 30 case initial 31 case initialUpdate = "initial-update" 32 case suspended 33 // case failed 34 case outdatedUpdate = "outdated-update" 35 case ready 36 case readyUpdate = "ready-update" 37 case unavailable // even if wallet-core doesn't support this, we need it for L10n 38 case unavailableUpdate = "unavailable-update" 39 40 var localized: String { 41 switch self { 42 case .initial: String(localized: "Exchange_status_initial", defaultValue: "Initial") 43 case .initialUpdate: String(localized: "Exchange_status_initial_update", defaultValue: "Initial (… updating …)") 44 case .suspended: String(localized: "Exchange_status_suspended", defaultValue: "Suspended") 45 // case .failed: String(localized: "Exchange_status_failed", defaultValue: "Failed") 46 case .outdatedUpdate: String(localized: "Exchange_status_outdated_update", defaultValue: "Outdated (… updating …)") 47 case .ready: String(localized: "Exchange_status_ready", defaultValue: "Ready") 48 case .readyUpdate: String(localized: "Exchange_status_ready_update", defaultValue: "Ready (… updating …)") 49 case .unavailable: String(localized: "Exchange_status_unavailable", defaultValue: "Unavailable") 50 case .unavailableUpdate: String(localized: "Exchange_status_unavailable_update", defaultValue: "Unavailable (… updating …)") 51 } 52 } 53 } 54 55 struct ExchangeState: Codable, Hashable { 56 var exchangeEntryStatus: ExchangeEntryStatus 57 var exchangeUpdateStatus: ExchangeUpdateStatus 58 var tosStatus: ExchangeTosStatus 59 } 60 61 /// Set on an Exchange when it changed its master key and the user has not 62 /// confirmed the change yet. Withdrawals are refused while this is present 63 /// (wallet-core throws WALLET_EXCHANGE_KEYS_NOT_ACCEPTED), until the app calls 64 /// confirmExchangeKeyChange(). 65 struct ExchangeKeyChangeInfo: Codable, Hashable { 66 var currentMasterPub: String 67 var currentCurrency: String 68 var supersededMasterPub: String 69 var supersededCurrency: String 70 var sharesDenominations: Bool 71 var firstSeen: Timestamp? 72 } 73 74 struct ExchangeTransition: Codable { // Notification 75 var type: String // exchange-state-transition 76 var exchangeBaseUrl: String 77 var oldExchangeState: ExchangeState 78 var newExchangeState: ExchangeState 79 } 80 81 enum BankDialect: Codable, Hashable { 82 case gls 83 case unknown(value: String) 84 85 init(from decoder: Decoder) throws { 86 let container = try decoder.singleValueContainer() 87 let string = try? container.decode(String.self) 88 switch string { 89 case "gls": self = .gls 90 default: 91 self = .unknown(value: string ?? "unknown") 92 } 93 } 94 } 95 96 enum ExchangeSource: Codable, Hashable { 97 case builtin 98 case user 99 case discovered 100 case unknown(value: String) 101 102 init(from decoder: Decoder) throws { 103 let container = try decoder.singleValueContainer() 104 let string = try? container.decode(String.self) 105 switch string { 106 case "builtin": self = .builtin 107 case "user": self = .user 108 case "discovered": self = .discovered 109 default: 110 self = .unknown(value: string ?? "unknown") 111 } 112 } 113 } 114 // MARK: - 115 /// The result from wallet-core's ListExchanges 116 struct Exchange: Codable, Hashable, Identifiable { 117 static func < (lhs: Exchange, rhs: Exchange) -> Bool { 118 let leftScope = lhs.scopeInfo 119 let rightScope = rhs.scopeInfo 120 return leftScope < rightScope 121 } 122 static func == (lhs: Exchange, rhs: Exchange) -> Bool { 123 return lhs.exchangeBaseUrl == rhs.exchangeBaseUrl 124 && lhs.tosStatus == rhs.tosStatus 125 // && lhs.exchangeStatus == rhs.exchangeStatus // deprecated 126 && lhs.exchangeEntryStatus == rhs.exchangeEntryStatus 127 && lhs.exchangeUpdateStatus == rhs.exchangeUpdateStatus 128 } 129 130 var exchangeBaseUrl: String 131 var source: ExchangeSource? 132 var masterPub: String? 133 var legacyMasterPubs: [String]? 134 var scopeInfo: ScopeInfo 135 var paytoUris: [String] 136 var tosStatus: ExchangeTosStatus 137 var exchangeEntryStatus: ExchangeEntryStatus 138 var exchangeUpdateStatus: ExchangeUpdateStatus 139 var peerPaymentsDisabled: Bool? 140 var directDepositsDisabled: Bool? 141 var noFees: Bool? 142 var ageRestrictionOptions: [Int] 143 var bankComplianceLanguage: BankDialect? 144 var lastUpdateTimestamp: Timestamp? 145 var lastWithdrawal: Timestamp? 146 var lastUpdateErrorInfo: OperationErrorInfo? 147 var unconfirmedKeyChange: ExchangeKeyChangeInfo? // if set, withdrawals are refused until confirmed 148 var currencySpec: CurrencySpecification? // .name = "Swiss Francs"... alt_unit_names":{"0":"Fr.","-2":"Rp."} 149 150 // walletKycStatus?: ExchangeWalletKycStatus; 151 // walletKycReservePub?: string; 152 // walletKycAccessToken?: string; 153 // walletKycUrl?: string; 154 155 /** Threshold that we've requested to satisfy. */ 156 // walletKycRequestedThreshold?: string; 157 158 var id: String { exchangeBaseUrl + tosStatus.rawValue } 159 var name: String? { 160 if let url = URL(string: exchangeBaseUrl) { 161 if let host = url.host { 162 return host 163 } 164 } 165 return nil 166 } 167 } 168 // MARK: - 169 struct DefaultExchange: Codable, Hashable { 170 var talerUri: String // taler://withdraw-exchange/exchange.taler-ops.ch/ 171 var exchangeBaseUrl: String // https://exchange.taler-ops.ch/ 172 var currency: String // CHF 173 var currencySpec: CurrencySpecification // .name = "Swiss Francs"... alt_unit_names":{"0":"Fr.","-2":"Rp."} 174 // var restrictions: [AccountRestriction]? // ["Swiss bank accounts only"] too bad, need to contact server to get this data 175 var exchangeEntryStatus: ExchangeEntryStatus 176 var exchangeUpdateStatus: ExchangeUpdateStatus 177 var recommendationReasons: [String]? 178 } 179 180 extension DefaultExchange: Identifiable { 181 var id: String { exchangeBaseUrl } 182 } 183 // MARK: - 184 /// A request to list exchanges names for a currency 185 fileprivate struct ListExchanges: WalletBackendFormattedRequest { 186 var operation: String { "listExchanges" } 187 func args() -> Args { Args(filterByScope: scope, filterByType: filterByType, filterByExchangeEntryStatus: filterByStatus) } 188 189 var scope: ScopeInfo? 190 var filterByType: ExchangeType? 191 var filterByStatus: ExchangeEntryStatus? 192 struct Args: Encodable { 193 var filterByScope: ScopeInfo? 194 var filterByType: ExchangeType? 195 var filterByExchangeEntryStatus: ExchangeEntryStatus? 196 } 197 struct Response: Decodable { // list of known exchanges 198 var exchanges: [Exchange] 199 } 200 } 201 202 fileprivate struct DefaultExchanges: WalletBackendFormattedRequest { 203 var operation: String { "listWithdrawalExchangeCandidates" } 204 func args() -> Args { Args(presetOnly: true) } 205 206 struct Args: Encodable { 207 var presetOnly: Bool 208 } 209 210 struct Response: Decodable { // list of known exchanges 211 var candidates: [DefaultExchange] 212 } 213 } 214 215 /// A request to get info for one exchange. 216 fileprivate struct GetExchangeByUrl: WalletBackendFormattedRequest { 217 var operation: String { "getExchangeEntryByUrl" } 218 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl) } 219 220 var exchangeBaseUrl: String 221 222 struct Args: Encodable { 223 var exchangeBaseUrl: String 224 } 225 typealias Response = Exchange 226 } 227 228 /// A request to update a single exchange. 229 fileprivate struct UpdateExchange: WalletBackendFormattedRequest { 230 var operation: String { "updateExchangeEntry" } 231 // func args() -> Args { Args(scopeInfo: scopeInfo) } 232 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl, force: force) } 233 234 // var scopeInfo: ScopeInfo 235 var exchangeBaseUrl: String 236 var force: Bool 237 struct Args: Encodable { 238 var exchangeBaseUrl: String 239 var force: Bool 240 } 241 struct Response: Decodable {} // no result - getting no error back means success 242 } 243 244 /// A request to add an exchange. 245 fileprivate struct AddExchange: WalletBackendFormattedRequest { 246 var operation: String { "addExchange" } 247 func args() -> Args { Args(uri: uri, allowCompletion: true, 248 progressToken: uri) } 249 var uri: String 250 struct Args: Encodable { 251 var uri: String 252 var allowCompletion: Bool 253 var progressToken: String 254 } 255 struct Response: Decodable {} // no result - getting no error back means success 256 } 257 258 /// A request to delete an exchange. 259 fileprivate struct DeleteExchange: WalletBackendFormattedRequest { 260 var operation: String { "deleteExchange" } 261 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl, purge: purge) } 262 263 var exchangeBaseUrl: String 264 var purge: Bool 265 struct Args: Encodable { 266 var exchangeBaseUrl: String 267 var purge: Bool 268 } 269 struct Response: Decodable {} // no result - getting no error back means success 270 } 271 272 /// A request to confirm that a changed exchange key set is legitimate. 273 /// Needed before a withdrawal can proceed once `unconfirmedKeyChange` is set. 274 fileprivate struct ConfirmExchangeKeyChange: WalletBackendFormattedRequest { 275 var operation: String { "confirmExchangeKeyChange" } 276 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl, currentMasterPub: currentMasterPub) } 277 278 var exchangeBaseUrl: String 279 var currentMasterPub: String 280 struct Args: Encodable { 281 var exchangeBaseUrl: String 282 var currentMasterPub: String 283 } 284 struct Response: Decodable {} // no result - getting no error back means success 285 } 286 fileprivate struct PurgeExchangeLegacyKeys: WalletBackendFormattedRequest { 287 var operation: String { "purgeExchangeLegacyKeys" } 288 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl, currentMasterPub: currentMasterPub) } 289 290 var exchangeBaseUrl: String 291 var currentMasterPub: String 292 struct Args: Encodable { 293 var exchangeBaseUrl: String 294 var currentMasterPub: String 295 } 296 struct Response: Decodable {} // no result - getting no error back means success 297 } 298 /// A request to get info about a currency 299 fileprivate struct GetCurrencySpecification: WalletBackendFormattedRequest { 300 var operation: String { "getCurrencySpecification" } 301 func args() -> Args { Args(scope: scope) } 302 303 var scope: ScopeInfo 304 struct Args: Encodable { 305 var scope: ScopeInfo 306 } 307 struct Response: Codable, Sendable { 308 let currencySpecification: CurrencySpecification 309 } 310 } 311 /// A request to make a currency "global" 312 fileprivate struct AddGlobalCurrency: WalletBackendFormattedRequest { 313 var operation: String { "addGlobalCurrencyExchange" } 314 func args() -> Args { Args(currency: currency, 315 exchangeBaseUrl: baseUrl, 316 exchangeMasterPub: masterPub) } 317 var currency: String 318 var baseUrl: String 319 var masterPub: String 320 struct Args: Encodable { 321 var currency: String 322 var exchangeBaseUrl: String 323 var exchangeMasterPub: String 324 } 325 struct Response: Decodable {} // no result - getting no error back means success 326 } 327 fileprivate struct RmvGlobalCurrency: WalletBackendFormattedRequest { 328 var operation: String { "removeGlobalCurrencyExchange" } 329 func args() -> Args { Args(currency: currency, 330 exchangeBaseUrl: baseUrl, 331 exchangeMasterPub: masterPub) } 332 var currency: String 333 var baseUrl: String 334 var masterPub: String 335 struct Args: Encodable { 336 var currency: String 337 var exchangeBaseUrl: String 338 var exchangeMasterPub: String 339 } 340 struct Response: Decodable {} // no result - getting no error back means success 341 } 342 // MARK: - 343 extension WalletModel { 344 /// ask wallet-core for its list of known exchanges 345 nonisolated func listExchanges(scope: ScopeInfo?, filterByType: ExchangeType? = nil, 346 filterByStatus: ExchangeEntryStatus? = nil, viewHandles: Bool = false) 347 async -> [Exchange] { // M for MainActor 348 do { 349 let request = ListExchanges(scope: scope, 350 filterByType: filterByType, // .demo, .prod 351 filterByStatus: filterByStatus) // .used, .preset 352 let response = try await sendRequest(request, viewHandles: viewHandles) 353 return response.exchanges 354 } catch { 355 return [] // empty, but not nil 356 } 357 } 358 359 /// ask wallet-core for its list of default exchanges ==> currently only taler-ops.ch 360 nonisolated func getDefaultExchanges(viewHandles: Bool = false) 361 async -> [DefaultExchange] { // M for MainActor 362 do { 363 let request = DefaultExchanges() 364 let response = try await sendRequest(request, viewHandles: viewHandles) 365 return response.candidates 366 } catch { 367 return [] // empty, but not nil 368 } 369 } 370 371 /// add a new exchange with URL to the wallet's list of known exchanges 372 nonisolated func getExchangeByUrl(url: String, viewHandles: Bool = false) 373 async throws -> Exchange { 374 let request = GetExchangeByUrl(exchangeBaseUrl: url) 375 // logger.info("query for exchange: \(url, privacy: .public)") 376 let response = try await sendRequest(request, viewHandles: viewHandles) 377 return response 378 } 379 380 /// add a new exchange with URL to the wallet's list of known exchanges 381 nonisolated func addExchange(uri: String, viewHandles: Bool = false) 382 async throws { 383 let request = AddExchange(uri: uri) 384 logger.info("adding exchange: \(uri, privacy: .public)") 385 let controller = Controller.shared 386 controller.progressOperation = request.operation 387 controller.progressToken = uri 388 _ = try await sendRequest(request, viewHandles: viewHandles) 389 } 390 391 /// add a new exchange with URL to the wallet's list of known exchanges 392 nonisolated func deleteExchange(url: String, purge: Bool = false, viewHandles: Bool = false) 393 async throws { 394 let request = DeleteExchange(exchangeBaseUrl: url, purge: purge) 395 logger.info("deleting exchange: \(url, privacy: .public)") 396 _ = try await sendRequest(request, viewHandles: viewHandles) 397 } 398 399 /// ask wallet-core to update an existing exchange by querying it for denominations, fees, and scoped currency info 400 // func updateExchange(scopeInfo: ScopeInfo, viewHandles: Bool = false) 401 nonisolated func updateExchange(exchangeBaseUrl: String, force: Bool = false, viewHandles: Bool = false) 402 async throws { 403 let request = UpdateExchange(exchangeBaseUrl: exchangeBaseUrl, force: force) 404 logger.info("updating exchange: \(exchangeBaseUrl, privacy: .public)") 405 _ = try await sendRequest(request, viewHandles: viewHandles) 406 } 407 408 /// confirm a changed exchange master key so withdrawals are allowed again 409 nonisolated func confirmExchangeKeyChange(exchangeBaseUrl: String, currentMasterPub: String, viewHandles: Bool = false) 410 async throws { 411 let request = ConfirmExchangeKeyChange(exchangeBaseUrl: exchangeBaseUrl, currentMasterPub: currentMasterPub) 412 logger.info("confirming exchange key change: \(exchangeBaseUrl, privacy: .public)") 413 _ = try await sendRequest(request, viewHandles: viewHandles) 414 } 415 416 nonisolated func purgeExchangeLegacyKeys(exchangeBaseUrl: String, currentMasterPub: String, viewHandles: Bool = false) 417 async throws { 418 let request = PurgeExchangeLegacyKeys(exchangeBaseUrl: exchangeBaseUrl, currentMasterPub: currentMasterPub) 419 logger.info("purging old exchange keys: \(exchangeBaseUrl, privacy: .public)") 420 _ = try await sendRequest(request, viewHandles: viewHandles) 421 } 422 423 nonisolated func getCurrencyInfo(scope: ScopeInfo, viewHandles: Bool = false) 424 async throws -> CurrencyInfo { 425 let request = GetCurrencySpecification(scope: scope) 426 let response = try await sendRequest(request, viewHandles: viewHandles) 427 return CurrencyInfo(specs: response.currencySpecification, 428 formatter: CurrencyFormatter.formatter(currency: scope.currency, 429 specs: response.currencySpecification)) 430 } 431 432 nonisolated func addGlobalCurrencyExchange(currency: String, baseUrl: String, masterPub: String, viewHandles: Bool = false) 433 async throws { 434 let request = AddGlobalCurrency(currency: currency, baseUrl: baseUrl, masterPub: masterPub) 435 _ = try await sendRequest(request, viewHandles: viewHandles) 436 } 437 438 nonisolated func rmvGlobalCurrencyExchange(currency: String, baseUrl: String, masterPub: String, viewHandles: Bool = false) 439 async throws { 440 let request = RmvGlobalCurrency(currency: currency, baseUrl: baseUrl, masterPub: masterPub) 441 _ = try await sendRequest(request, viewHandles: viewHandles) 442 } 443 }