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