Model+Exchange.swift (14260B)
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 struct ExchangeTransition: Codable { // Notification 62 var type: String // exchange-state-transition 63 var exchangeBaseUrl: String 64 var oldExchangeState: ExchangeState 65 var newExchangeState: ExchangeState 66 } 67 68 enum BankDialect: Codable, Hashable { 69 case gls 70 case unknown(value: String) 71 72 init(from decoder: Decoder) throws { 73 let container = try decoder.singleValueContainer() 74 let status = try? container.decode(String.self) 75 switch status { 76 case "gls": self = .gls 77 default: 78 self = .unknown(value: status ?? "unknown") 79 } 80 } 81 } 82 // MARK: - 83 /// The result from wallet-core's ListExchanges 84 struct Exchange: Codable, Hashable, Identifiable { 85 static func < (lhs: Exchange, rhs: Exchange) -> Bool { 86 let leftScope = lhs.scopeInfo 87 let rightScope = rhs.scopeInfo 88 return leftScope < rightScope 89 } 90 static func == (lhs: Exchange, rhs: Exchange) -> Bool { 91 return lhs.exchangeBaseUrl == rhs.exchangeBaseUrl 92 && lhs.tosStatus == rhs.tosStatus 93 // && lhs.exchangeStatus == rhs.exchangeStatus // deprecated 94 && lhs.exchangeEntryStatus == rhs.exchangeEntryStatus 95 && lhs.exchangeUpdateStatus == rhs.exchangeUpdateStatus 96 } 97 98 var exchangeBaseUrl: String 99 var masterPub: String? 100 var scopeInfo: ScopeInfo 101 var paytoUris: [String] 102 var tosStatus: ExchangeTosStatus 103 var exchangeEntryStatus: ExchangeEntryStatus 104 var exchangeUpdateStatus: ExchangeUpdateStatus 105 var peerPaymentsDisabled: Bool? 106 var directDepositsDisabled: Bool? 107 var noFees: Bool? 108 var ageRestrictionOptions: [Int] 109 var bankComplianceLanguage: BankDialect? 110 var lastUpdateTimestamp: Timestamp? 111 var lastUpdateErrorInfo: OperationErrorInfo? 112 113 // walletKycStatus?: ExchangeWalletKycStatus; 114 // walletKycReservePub?: string; 115 // walletKycAccessToken?: string; 116 // walletKycUrl?: string; 117 118 /** Threshold that we've requested to satisfy. */ 119 // walletKycRequestedThreshold?: string; 120 121 var id: String { exchangeBaseUrl + tosStatus.rawValue } 122 var name: String? { 123 if let url = URL(string: exchangeBaseUrl) { 124 if let host = url.host { 125 return host 126 } 127 } 128 return nil 129 } 130 } 131 // MARK: - 132 struct DefaultExchange: Codable, Hashable { 133 var talerUri: String // taler://withdraw-exchange/exchange.taler-ops.ch/ 134 var exchangeBaseUrl: String // https://exchange.taler-ops.ch/ 135 var currency: String // CHF 136 var currencySpec: CurrencySpecification // .name = "Swiss Francs"... alt_unit_names":{"0":"Fr.","-2":"Rp."} 137 // var restrictions: [AccountRestriction]? // ["Swiss bank accounts only"] too bad, need to contact server to get this data 138 var exchangeEntryStatus: ExchangeEntryStatus 139 var exchangeUpdateStatus: ExchangeUpdateStatus 140 var recommendationReasons: [String]? 141 } 142 143 extension DefaultExchange: Identifiable { 144 var id: String { exchangeBaseUrl } 145 } 146 // MARK: - 147 /// A request to list exchanges names for a currency 148 fileprivate struct ListExchanges: WalletBackendFormattedRequest { 149 var operation: String { "listExchanges" } 150 func args() -> Args { Args(filterByScope: scope, filterByType: filterByType, filterByExchangeEntryStatus: filterByStatus) } 151 152 var scope: ScopeInfo? 153 var filterByType: ExchangeType? 154 var filterByStatus: ExchangeEntryStatus? 155 struct Args: Encodable { 156 var filterByScope: ScopeInfo? 157 var filterByType: ExchangeType? 158 var filterByExchangeEntryStatus: ExchangeEntryStatus? 159 } 160 struct Response: Decodable { // list of known exchanges 161 var exchanges: [Exchange] 162 } 163 } 164 165 fileprivate struct DefaultExchanges: WalletBackendFormattedRequest { 166 var operation: String { "listWithdrawalExchangeCandidates" } 167 func args() -> Args { Args(presetOnly: true) } 168 169 struct Args: Encodable { 170 var presetOnly: Bool 171 } 172 173 struct Response: Decodable { // list of known exchanges 174 var candidates: [DefaultExchange] 175 } 176 } 177 178 /// A request to get info for one exchange. 179 fileprivate struct GetExchangeByUrl: WalletBackendFormattedRequest { 180 var operation: String { "getExchangeEntryByUrl" } 181 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl) } 182 183 var exchangeBaseUrl: String 184 185 struct Args: Encodable { 186 var exchangeBaseUrl: String 187 } 188 typealias Response = Exchange 189 } 190 191 /// A request to update a single exchange. 192 fileprivate struct UpdateExchange: WalletBackendFormattedRequest { 193 var operation: String { "updateExchangeEntry" } 194 // func args() -> Args { Args(scopeInfo: scopeInfo) } 195 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl, force: force) } 196 197 // var scopeInfo: ScopeInfo 198 var exchangeBaseUrl: String 199 var force: Bool 200 struct Args: Encodable { 201 var exchangeBaseUrl: String 202 var force: Bool 203 } 204 struct Response: Decodable {} // no result - getting no error back means success 205 } 206 207 /// A request to add an exchange. 208 fileprivate struct AddExchange: WalletBackendFormattedRequest { 209 var operation: String { "addExchange" } 210 func args() -> Args { Args(uri: uri, allowCompletion: true, 211 progressToken: uri) } 212 var uri: String 213 struct Args: Encodable { 214 var uri: String 215 var allowCompletion: Bool 216 var progressToken: String 217 } 218 struct Response: Decodable {} // no result - getting no error back means success 219 } 220 221 /// A request to delete an exchange. 222 fileprivate struct DeleteExchange: WalletBackendFormattedRequest { 223 var operation: String { "deleteExchange" } 224 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl, purge: purge) } 225 226 var exchangeBaseUrl: String 227 var purge: Bool 228 struct Args: Encodable { 229 var exchangeBaseUrl: String 230 var purge: Bool 231 } 232 struct Response: Decodable {} // no result - getting no error back means success 233 } 234 235 /// A request to get info about a currency 236 fileprivate struct GetCurrencySpecification: WalletBackendFormattedRequest { 237 var operation: String { "getCurrencySpecification" } 238 func args() -> Args { Args(scope: scope) } 239 240 var scope: ScopeInfo 241 struct Args: Encodable { 242 var scope: ScopeInfo 243 } 244 struct Response: Codable, Sendable { 245 let currencySpecification: CurrencySpecification 246 } 247 } 248 /// A request to make a currency "global" 249 fileprivate struct AddGlobalCurrency: WalletBackendFormattedRequest { 250 var operation: String { "addGlobalCurrencyExchange" } 251 func args() -> Args { Args(currency: currency, 252 exchangeBaseUrl: baseUrl, 253 exchangeMasterPub: masterPub) } 254 var currency: String 255 var baseUrl: String 256 var masterPub: String 257 struct Args: Encodable { 258 var currency: String 259 var exchangeBaseUrl: String 260 var exchangeMasterPub: String 261 } 262 struct Response: Decodable {} // no result - getting no error back means success 263 } 264 fileprivate struct RmvGlobalCurrency: WalletBackendFormattedRequest { 265 var operation: String { "removeGlobalCurrencyExchange" } 266 func args() -> Args { Args(currency: currency, 267 exchangeBaseUrl: baseUrl, 268 exchangeMasterPub: masterPub) } 269 var currency: String 270 var baseUrl: String 271 var masterPub: String 272 struct Args: Encodable { 273 var currency: String 274 var exchangeBaseUrl: String 275 var exchangeMasterPub: String 276 } 277 struct Response: Decodable {} // no result - getting no error back means success 278 } 279 // MARK: - 280 extension WalletModel { 281 /// ask wallet-core for its list of known exchanges 282 nonisolated func listExchanges(scope: ScopeInfo?, filterByType: ExchangeType? = nil, 283 filterByStatus: ExchangeEntryStatus? = nil, viewHandles: Bool = false) 284 async -> [Exchange] { // M for MainActor 285 do { 286 let request = ListExchanges(scope: scope, 287 filterByType: filterByType, // .demo, .prod 288 filterByStatus: filterByStatus) // .used, .preset 289 let response = try await sendRequest(request, viewHandles: viewHandles) 290 return response.exchanges 291 } catch { 292 return [] // empty, but not nil 293 } 294 } 295 296 /// ask wallet-core for its list of default exchanges ==> currently only taler-ops.ch 297 nonisolated func getDefaultExchanges(viewHandles: Bool = false) 298 async -> [DefaultExchange] { // M for MainActor 299 do { 300 let request = DefaultExchanges() 301 let response = try await sendRequest(request, viewHandles: viewHandles) 302 return response.candidates 303 } catch { 304 return [] // empty, but not nil 305 } 306 } 307 308 /// add a new exchange with URL to the wallet's list of known exchanges 309 nonisolated func getExchangeByUrl(url: String, viewHandles: Bool = false) 310 async throws -> Exchange { 311 let request = GetExchangeByUrl(exchangeBaseUrl: url) 312 // logger.info("query for exchange: \(url, privacy: .public)") 313 let response = try await sendRequest(request, viewHandles: viewHandles) 314 return response 315 } 316 317 /// add a new exchange with URL to the wallet's list of known exchanges 318 nonisolated func addExchange(uri: String, viewHandles: Bool = false) 319 async throws { 320 let request = AddExchange(uri: uri) 321 logger.info("adding exchange: \(uri, privacy: .public)") 322 let controller = Controller.shared 323 controller.progressOperation = request.operation 324 controller.progressToken = uri 325 _ = try await sendRequest(request, viewHandles: viewHandles) 326 } 327 328 /// add a new exchange with URL to the wallet's list of known exchanges 329 nonisolated func deleteExchange(url: String, purge: Bool = false, viewHandles: Bool = false) 330 async throws { 331 let request = DeleteExchange(exchangeBaseUrl: url, purge: purge) 332 logger.info("deleting exchange: \(url, privacy: .public)") 333 _ = try await sendRequest(request, viewHandles: viewHandles) 334 } 335 336 /// ask wallet-core to update an existing exchange by querying it for denominations, fees, and scoped currency info 337 // func updateExchange(scopeInfo: ScopeInfo, viewHandles: Bool = false) 338 nonisolated func updateExchange(exchangeBaseUrl: String, force: Bool = false, viewHandles: Bool = false) 339 async throws { 340 let request = UpdateExchange(exchangeBaseUrl: exchangeBaseUrl, force: force) 341 logger.info("updating exchange: \(exchangeBaseUrl, privacy: .public)") 342 _ = try await sendRequest(request, viewHandles: viewHandles) 343 } 344 345 nonisolated func getCurrencyInfo(scope: ScopeInfo, viewHandles: Bool = false) 346 async throws -> CurrencyInfo { 347 let request = GetCurrencySpecification(scope: scope) 348 let response = try await sendRequest(request, viewHandles: viewHandles) 349 return CurrencyInfo(specs: response.currencySpecification, 350 formatter: CurrencyFormatter.formatter(currency: scope.currency, 351 specs: response.currencySpecification)) 352 } 353 354 nonisolated func addGlobalCurrencyExchange(currency: String, baseUrl: String, masterPub: String, viewHandles: Bool = false) 355 async throws { 356 let request = AddGlobalCurrency(currency: currency, baseUrl: baseUrl, masterPub: masterPub) 357 _ = try await sendRequest(request, viewHandles: viewHandles) 358 } 359 360 nonisolated func rmvGlobalCurrencyExchange(currency: String, baseUrl: String, masterPub: String, viewHandles: Bool = false) 361 async throws { 362 let request = RmvGlobalCurrency(currency: currency, baseUrl: baseUrl, masterPub: masterPub) 363 _ = try await sendRequest(request, viewHandles: viewHandles) 364 } 365 }