taler-ios

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

Model+Exchange.swift (17726B)


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