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