Model+Exchange.swift (13840B)
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 // func args() -> Args { Args(stage: stage) } 168 169 var stage: Bool 170 struct Args: Encodable { 171 // var stage: Bool 172 } 173 174 struct Response: Decodable { // list of known exchanges 175 var defaultExchanges: [DefaultExchange] 176 } 177 } 178 179 /// A request to get info for one exchange. 180 fileprivate struct GetExchangeByUrl: WalletBackendFormattedRequest { 181 func operation() -> String { "getExchangeEntryByUrl" } 182 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl) } 183 184 var exchangeBaseUrl: String 185 186 struct Args: Encodable { 187 var exchangeBaseUrl: String 188 } 189 typealias Response = Exchange 190 } 191 192 /// A request to update a single exchange. 193 fileprivate struct UpdateExchange: WalletBackendFormattedRequest { 194 func operation() -> String { "updateExchangeEntry" } 195 // func args() -> Args { Args(scopeInfo: scopeInfo) } 196 func args() -> Args { Args(exchangeBaseUrl: exchangeBaseUrl, force: force) } 197 198 // var scopeInfo: ScopeInfo 199 var exchangeBaseUrl: String 200 var force: Bool 201 struct Args: Encodable { 202 var exchangeBaseUrl: String 203 var force: Bool 204 } 205 struct Response: Decodable {} // no result - getting no error back means success 206 } 207 208 /// A request to add an exchange. 209 fileprivate struct AddExchange: WalletBackendFormattedRequest { 210 func operation() -> String { "addExchange" } 211 func args() -> Args { Args(uri: uri, allowCompletion: true) } 212 213 var uri: String 214 struct Args: Encodable { 215 var uri: String 216 var allowCompletion: Bool 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 func 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 func 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 func 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 func 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(stage: Bool, viewHandles: Bool = false) 298 async -> [DefaultExchange] { // M for MainActor 299 do { 300 let request = DefaultExchanges(stage: stage) 301 let response = try await sendRequest(request, viewHandles: viewHandles) 302 return response.defaultExchanges 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 _ = try await sendRequest(request, viewHandles: viewHandles) 323 } 324 325 /// add a new exchange with URL to the wallet's list of known exchanges 326 nonisolated func deleteExchange(url: String, purge: Bool = false, viewHandles: Bool = false) 327 async throws { 328 let request = DeleteExchange(exchangeBaseUrl: url, purge: purge) 329 logger.info("deleting exchange: \(url, privacy: .public)") 330 _ = try await sendRequest(request, viewHandles: viewHandles) 331 } 332 333 /// ask wallet-core to update an existing exchange by querying it for denominations, fees, and scoped currency info 334 // func updateExchange(scopeInfo: ScopeInfo, viewHandles: Bool = false) 335 nonisolated func updateExchange(exchangeBaseUrl: String, force: Bool = false, viewHandles: Bool = false) 336 async throws { 337 let request = UpdateExchange(exchangeBaseUrl: exchangeBaseUrl, force: force) 338 logger.info("updating exchange: \(exchangeBaseUrl, privacy: .public)") 339 _ = try await sendRequest(request, viewHandles: viewHandles) 340 } 341 342 nonisolated func getCurrencyInfo(scope: ScopeInfo, viewHandles: Bool = false) 343 async throws -> CurrencyInfo { 344 let request = GetCurrencySpecification(scope: scope) 345 let response = try await sendRequest(request, viewHandles: viewHandles) 346 return CurrencyInfo(specs: response.currencySpecification, 347 formatter: CurrencyFormatter.formatter(currency: scope.currency, 348 specs: response.currencySpecification)) 349 } 350 351 nonisolated func addGlobalCurrencyExchange(currency: String, baseUrl: String, masterPub: String, viewHandles: Bool = false) 352 async throws { 353 let request = AddGlobalCurrency(currency: currency, baseUrl: baseUrl, masterPub: masterPub) 354 _ = try await sendRequest(request, viewHandles: viewHandles) 355 } 356 357 nonisolated func rmvGlobalCurrencyExchange(currency: String, baseUrl: String, masterPub: String, viewHandles: Bool = false) 358 async throws { 359 let request = RmvGlobalCurrency(currency: currency, baseUrl: baseUrl, masterPub: masterPub) 360 _ = try await sendRequest(request, viewHandles: viewHandles) 361 } 362 }