taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }