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