summaryrefslogtreecommitdiff
path: root/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange
diff options
context:
space:
mode:
Diffstat (limited to 'wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange')
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Auditor.kt56
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Denomination.kt234
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Exchange.kt262
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/ExchangeRecord.kt151
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Keys.kt97
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Wire.kt79
6 files changed, 879 insertions, 0 deletions
diff --git a/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Auditor.kt b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Auditor.kt
new file mode 100644
index 0000000..248da8d
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Auditor.kt
@@ -0,0 +1,56 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.lib.wallet.exchange
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Auditor information as given by the exchange in /keys.
+ */
+@Serializable
+data class Auditor(
+ /**
+ * Auditor's public key.
+ */
+ val auditor_pub: String,
+
+ /**
+ * Base URL of the auditor.
+ */
+ val auditor_url: String,
+
+ /**
+ * List of signatures for denominations by the auditor.
+ */
+ val denomination_keys: List<AuditorDenomSig>
+)
+
+/**
+ * Signature by the auditor that a particular denomination key is audited.
+ */
+@Serializable
+data class AuditorDenomSig(
+ /**
+ * Denomination public key's hash.
+ */
+ val denom_pub_h: String,
+
+ /**
+ * The signature.
+ */
+ val auditor_sig: String
+)
diff --git a/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Denomination.kt b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Denomination.kt
new file mode 100644
index 0000000..fca9e3f
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Denomination.kt
@@ -0,0 +1,234 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.lib.wallet.exchange
+
+import kotlinx.serialization.Serializable
+import net.taler.lib.common.Amount
+import net.taler.lib.common.Duration
+import net.taler.lib.common.Timestamp
+import net.taler.lib.wallet.Base32Crockford
+import net.taler.lib.wallet.exchange.DenominationStatus.Unverified
+import net.taler.lib.wallet.exchange.DenominationStatus.VerifiedGood
+
+/**
+ * Denomination as found in the /keys response from the exchange.
+ */
+@Serializable
+internal data class Denomination(
+ /**
+ * Value of one coin of the denomination.
+ */
+ val value: Amount,
+
+ /**
+ * Public signing key of the denomination.
+ */
+ val denom_pub: String,
+
+ /**
+ * Fee for withdrawing.
+ */
+ val fee_withdraw: Amount,
+
+ /**
+ * Fee for depositing.
+ */
+ val fee_deposit: Amount,
+
+ /**
+ * Fee for refreshing.
+ */
+ val fee_refresh: Amount,
+
+ /**
+ * Fee for refunding.
+ */
+ val fee_refund: Amount,
+
+ /**
+ * Start date from which withdraw is allowed.
+ */
+ val stamp_start: Timestamp,
+
+ /**
+ * End date for withdrawing.
+ */
+ val stamp_expire_withdraw: Timestamp,
+
+ /**
+ * Expiration date after which the exchange can forget about
+ * the currency.
+ */
+ val stamp_expire_legal: Timestamp,
+
+ /**
+ * Date after which the coins of this denomination can't be
+ * deposited anymore.
+ */
+ val stamp_expire_deposit: Timestamp,
+
+ /**
+ * Signature over the denomination information by the exchange's master
+ * signing key.
+ */
+ val master_sig: String
+) {
+ fun toDenominationRecord(
+ baseUrl: String,
+ denomPubHash: ByteArray,
+ isOffered: Boolean,
+ isRevoked: Boolean,
+ status: DenominationStatus
+ ): DenominationRecord =
+ DenominationRecord(
+ denomPub = denom_pub,
+ denomPubHash = Base32Crockford.encode(denomPubHash),
+ exchangeBaseUrl = baseUrl,
+ feeDeposit = fee_deposit,
+ feeRefresh = fee_refresh,
+ feeRefund = fee_refund,
+ feeWithdraw = fee_withdraw,
+ isOffered = isOffered,
+ isRevoked = isRevoked,
+ masterSig = master_sig,
+ stampExpireDeposit = stamp_expire_deposit,
+ stampExpireLegal = stamp_expire_legal,
+ stampExpireWithdraw = stamp_expire_withdraw,
+ stampStart = stamp_start,
+ status = status,
+ value = value
+ )
+}
+
+enum class DenominationStatus {
+ /**
+ * Verification was delayed.
+ */
+ Unverified,
+
+ /**
+ * Verified as valid.
+ */
+ VerifiedGood,
+
+ /**
+ * Verified as invalid.
+ */
+ VerifiedBad
+}
+
+data class DenominationRecord(
+ /**
+ * Value of one coin of the denomination.
+ */
+ val value: Amount,
+ /**
+ * The denomination public key.
+ */
+ val denomPub: String,
+ /**
+ * Hash of the denomination public key.
+ * Stored in the database for faster lookups.
+ */
+ val denomPubHash: String,
+ /**
+ * Fee for withdrawing.
+ */
+ val feeWithdraw: Amount,
+ /**
+ * Fee for depositing.
+ */
+ val feeDeposit: Amount,
+ /**
+ * Fee for refreshing.
+ */
+ val feeRefresh: Amount,
+ /**
+ * Fee for refunding.
+ */
+ val feeRefund: Amount,
+ /**
+ * Validity start date of the denomination.
+ */
+ val stampStart: Timestamp,
+ /**
+ * Date after which the currency can't be withdrawn anymore.
+ */
+ val stampExpireWithdraw: Timestamp,
+ /**
+ * Date after the denomination officially doesn't exist anymore.
+ */
+ val stampExpireLegal: Timestamp,
+ /**
+ * Data after which coins of this denomination can't be deposited anymore.
+ */
+ val stampExpireDeposit: Timestamp,
+ /**
+ * Signature by the exchange's master key over the denomination
+ * information.
+ */
+ val masterSig: String,
+ /**
+ * Did we verify the signature on the denomination?
+ */
+ val status: DenominationStatus,
+ /**
+ * Was this denomination still offered by the exchange the last time
+ * we checked?
+ * Only false when the exchange redacts a previously published denomination.
+ */
+ val isOffered: Boolean,
+ /**
+ * Did the exchange revoke the denomination?
+ * When this field is set to true in the database, the same transaction
+ * should also mark all affected coins as revoked.
+ */
+ val isRevoked: Boolean,
+ /**
+ * Base URL of the exchange.
+ */
+ val exchangeBaseUrl: String
+) {
+ fun isWithdrawable(now: Timestamp = Timestamp.now()): Boolean {
+ if (isRevoked) return false // can not use revoked denomination
+ if (status != Unverified && status != VerifiedGood) return false // verified to be bad
+ if (now < stampStart) return false // denomination has not yet started
+ val lastPossibleWithdraw = stampExpireWithdraw - Duration(50 * 1000)
+ if ((lastPossibleWithdraw - now).ms == 0L) return false // denomination has expired
+ return true
+ }
+}
+
+data class DenominationSelectionInfo(
+ val totalCoinValue: Amount,
+ val totalWithdrawCost: Amount,
+ val selectedDenominations: List<SelectedDenomination>
+) {
+ fun getEarliestDepositExpiry(): Timestamp {
+ if (selectedDenominations.isEmpty()) return Timestamp(
+ Timestamp.NEVER
+ )
+ var earliest = selectedDenominations[0].denominationRecord.stampExpireDeposit
+ for (i in 1 until selectedDenominations.size) {
+ val stampExpireDeposit = selectedDenominations[i].denominationRecord.stampExpireDeposit
+ if (stampExpireDeposit < earliest) earliest = stampExpireDeposit
+ }
+ return earliest
+ }
+}
+
+data class SelectedDenomination(val count: Int, val denominationRecord: DenominationRecord)
diff --git a/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Exchange.kt b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Exchange.kt
new file mode 100644
index 0000000..4d89cd6
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Exchange.kt
@@ -0,0 +1,262 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.lib.wallet.exchange
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.accept
+import io.ktor.client.request.get
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.readText
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import net.taler.lib.common.Amount
+import net.taler.lib.common.Timestamp
+import net.taler.lib.common.Version
+import net.taler.lib.wallet.Base32Crockford
+import net.taler.lib.wallet.Db
+import net.taler.lib.wallet.DbFactory
+import net.taler.lib.wallet.crypto.Crypto
+import net.taler.lib.wallet.crypto.CryptoFactory
+import net.taler.lib.wallet.crypto.Signature
+import net.taler.lib.wallet.exchange.DenominationStatus.Unverified
+import net.taler.lib.wallet.exchange.ExchangeUpdateReason.Initial
+import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.FetchKeys
+import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.FetchTerms
+import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.FetchWire
+import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.FinalizeUpdate
+import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.Finished
+import net.taler.lib.wallet.getDefaultHttpClient
+
+internal class Exchange(
+ private val crypto: Crypto = CryptoFactory.getCrypto(),
+ private val signature: Signature = Signature(crypto),
+ private val httpClient: HttpClient = getDefaultHttpClient(),
+ // using the default Http client adds a json Accept header to each request, so we need a different one
+ // because the exchange is returning XML when it doesn't exactly match a mime type.
+ private val httpNoJsonClient: HttpClient = HttpClient(),
+ private val db: Db = DbFactory().openDb()
+) {
+
+ companion object {
+ private val PROTOCOL_VERSION = Version(8, 0, 0)
+ fun getVersionMatch(version: String) = PROTOCOL_VERSION.compare(Version.parse(version))
+ fun normalizeUrl(exchangeBaseUrl: String): String {
+ var url = exchangeBaseUrl
+ if (!url.startsWith("http")) url = "http://$url"
+ if (!url.endsWith("/")) url = "$url/"
+ // TODO also remove query and hash
+ return url
+ }
+ }
+
+ /**
+ * Update or add exchange DB entry by fetching the /keys, /wire and /terms information.
+ */
+ suspend fun updateFromUrl(baseUrl: String): ExchangeRecord {
+ val now = Timestamp.now()
+ val url = normalizeUrl(baseUrl)
+ var record = db.getExchangeByBaseUrl(url) ?: ExchangeRecord(
+ baseUrl = url,
+ timestampAdded = now,
+ updateStatus = FetchKeys,
+ updateStarted = now,
+ updateReason = Initial
+ ).also { db.put(it) }
+ val recordBeforeUpdate = record.copy()
+
+ record = updateKeys(record) // TODO add denominations in transaction at the end
+ record = updateWireInfo(record)
+ record = updateTermsOfService(record)
+ record = finalizeUpdate(record)
+ db.transaction {
+ val dbRecord = getExchangeByBaseUrl(record.baseUrl)
+ if (dbRecord != recordBeforeUpdate) throw Error("Concurrent modification of $dbRecord")
+ put(record)
+ }
+ return record
+ }
+
+ /**
+ * Fetch the exchange's /keys and update database accordingly.
+ *
+ * Exceptions thrown in this method must be caught and reported in the pending operations.
+ */
+ internal suspend fun updateKeys(record: ExchangeRecord): ExchangeRecord {
+ val keys: Keys = Keys.fetch(httpClient, record.baseUrl)
+ // check if there are denominations offered
+ // TODO provide more error information for catcher
+ if (keys.denoms.isEmpty()) {
+ throw Error("Exchange doesn't offer any denominations")
+ }
+ // check if the exchange version is compatible
+ val versionMatch = getVersionMatch(keys.version)
+ if (versionMatch == null || !versionMatch.compatible) {
+ throw Error("Exchange protocol version not compatible with wallet")
+ }
+ val currency = keys.denoms[0].value.currency
+ val newDenominations = keys.denoms.map { d ->
+ getDenominationRecord(record.baseUrl, currency, d)
+ }
+ // update exchange details
+ val details = ExchangeDetails(
+ auditors = keys.auditors,
+ currency = currency,
+ lastUpdateTime = keys.list_issue_date,
+ masterPublicKey = keys.master_public_key,
+ protocolVersion = keys.version,
+ signingKeys = keys.signkeys
+ )
+ val updatedRecord = record.copy(details = details, updateStatus = FetchWire)
+ for (newDenomination in newDenominations) {
+ // TODO check oldDenominations and do consistency checks
+ db.put(newDenomination)
+ }
+
+ // TODO handle keys.recoup
+
+ return updatedRecord
+ }
+
+ /**
+ * Turn an exchange's denominations from /keys into [DenominationRecord]s
+ *
+ * Visible for testing.
+ */
+ internal fun getDenominationRecord(baseUrl: String, currency: String, d: Denomination): DenominationRecord {
+ checkCurrency(currency, d.value)
+ checkCurrency(currency, d.fee_refund)
+ checkCurrency(currency, d.fee_withdraw)
+ checkCurrency(currency, d.fee_refresh)
+ checkCurrency(currency, d.fee_deposit)
+ return d.toDenominationRecord(
+ baseUrl = baseUrl,
+ denomPubHash = crypto.sha512(Base32Crockford.decode(d.denom_pub)),
+ isOffered = true,
+ isRevoked = false,
+ status = Unverified
+ )
+ }
+
+ /**
+ * Fetch wire information for an exchange and store it in the database.
+ */
+ internal suspend fun updateWireInfo(record: ExchangeRecord): ExchangeRecord {
+ if (record.updateStatus != FetchWire) {
+ throw Error("Unexpected updateStatus: ${record.updateStatus}, expected: $FetchWire")
+ }
+ if (record.details == null) throw Error("Invalid exchange state")
+ val wire = Wire.fetch(httpClient, record.baseUrl)
+ // check account signatures
+ for (a in wire.accounts) {
+ val valid = signature.verifyWireAccount(
+ paytoUri = a.paytoUri,
+ signature = a.masterSig,
+ masterPub = record.details.masterPublicKey
+ )
+ if (!valid) throw Error("Exchange wire account signature invalid")
+ }
+ // check fee signatures
+ for (fee in wire.fees) {
+ val wireMethod = fee.key
+ val wireFees = fee.value
+ for (wireFee in wireFees) {
+ val valid = signature.verifyWireFee(
+ type = wireMethod,
+ wireFee = wireFee,
+ masterPub = record.details.masterPublicKey
+ )
+ if (!valid) throw Error("Exchange wire fee signature invalid")
+ checkCurrency(record.details.currency, wireFee.wireFee)
+ checkCurrency(record.details.currency, wireFee.closingFee)
+ }
+ }
+ val wireInfo = ExchangeWireInfo(
+ accounts = wire.accounts.map { ExchangeBankAccount(it.paytoUri) },
+ feesForType = wire.fees
+ )
+ return record.copy(updateStatus = FetchTerms, wireInfo = wireInfo)
+ }
+
+ /**
+ * Fetch wire information for an exchange and store it in the database.
+ */
+ internal suspend fun updateTermsOfService(record: ExchangeRecord): ExchangeRecord {
+ if (record.updateStatus != FetchTerms) {
+ throw Error("Unexpected updateStatus: ${record.updateStatus}, expected: $FetchTerms")
+ }
+ val response: HttpResponse = httpNoJsonClient.get("${record.baseUrl}terms") {
+ accept(ContentType.Text.Plain)
+ }
+ if (response.status != HttpStatusCode.OK) {
+ throw Error("/terms response has unexpected status code (${response.status.value})")
+ }
+ val text = response.readText()
+ val eTag = response.headers[HttpHeaders.ETag]
+ return record.copy(updateStatus = FinalizeUpdate, termsOfServiceText = text, termsOfServiceLastEtag = eTag)
+ }
+
+ internal fun finalizeUpdate(record: ExchangeRecord): ExchangeRecord {
+ if (record.updateStatus != FinalizeUpdate) {
+ throw Error("Unexpected updateStatus: ${record.updateStatus}, expected: $FinalizeUpdate")
+ }
+ // TODO store an event log for this update (exchangeUpdatedEvents)
+ return record.copy(updateStatus = Finished, addComplete = true)
+ }
+
+ private fun checkCurrency(currency: String, amount: Amount) {
+ if (currency != amount.currency) throw Error("Expected currency $currency, but found ${amount.currency}")
+ }
+
+}
+
+
+data class ExchangeListItem(
+ val exchangeBaseUrl: String,
+ val currency: String,
+ val paytoUris: List<String>
+) {
+ companion object {
+ fun fromExchangeRecord(exchange: ExchangeRecord): ExchangeListItem? {
+ return if (exchange.details == null || exchange.wireInfo == null) null
+ else ExchangeListItem(
+ exchangeBaseUrl = exchange.baseUrl,
+ currency = exchange.details.currency,
+ paytoUris = exchange.wireInfo.accounts.map {
+ it.paytoUri
+ }
+ )
+ }
+ }
+}
+
+data class GetExchangeTosResult(
+ /**
+ * Markdown version of the current ToS.
+ */
+ val tos: String,
+
+ /**
+ * Version tag of the current ToS.
+ */
+ val currentEtag: String,
+
+ /**
+ * Version tag of the last ToS that the user has accepted, if any.
+ */
+ val acceptedEtag: String? = null
+)
diff --git a/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/ExchangeRecord.kt b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/ExchangeRecord.kt
new file mode 100644
index 0000000..bb8bbd1
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/ExchangeRecord.kt
@@ -0,0 +1,151 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.lib.wallet.exchange
+
+import net.taler.lib.common.Timestamp
+
+/**
+ * Exchange record as stored in the wallet's database.
+ */
+data class ExchangeRecord(
+ /**
+ * Base url of the exchange.
+ */
+ val baseUrl: String,
+
+ /**
+ * Did we finish adding the exchange?
+ */
+ val addComplete: Boolean = false,
+
+ /**
+ * Was the exchange added as a built-in exchange?
+ */
+ val builtIn: Boolean = false,
+
+ /**
+ * Details, once known.
+ */
+ val details: ExchangeDetails? = null,
+
+ /**
+ * Mapping from wire method type to the wire fee.
+ */
+ val wireInfo: ExchangeWireInfo? = null,
+
+ /**
+ * When was the exchange added to the wallet?
+ */
+ val timestampAdded: Timestamp,
+
+ /**
+ * Terms of service text or undefined if not downloaded yet.
+ */
+ val termsOfServiceText: String? = null,
+
+ /**
+ * ETag for last terms of service download.
+ */
+ val termsOfServiceLastEtag: String? = null,
+
+ /**
+ * ETag for last terms of service download.
+ */
+ val termsOfServiceAcceptedEtag: String? = null,
+
+ /**
+ * ETag for last terms of service download.
+ */
+ val termsOfServiceAcceptedTimestamp: Timestamp? = null,
+
+ /**
+ * Time when the update to the exchange has been started or
+ * undefined if no update is in progress.
+ */
+ val updateStarted: Timestamp? = null,
+
+ val updateStatus: ExchangeUpdateStatus,
+
+ val updateReason: ExchangeUpdateReason? = null
+) {
+ init {
+ check(baseUrl == Exchange.normalizeUrl(baseUrl)) { "Base URL was not normalized" }
+ }
+
+ val termsOfServiceAccepted: Boolean
+ get() = termsOfServiceAcceptedTimestamp != null && termsOfServiceAcceptedEtag == termsOfServiceLastEtag
+}
+
+/**
+ * Details about the exchange that we only know after querying /keys and /wire.
+ */
+data class ExchangeDetails(
+ /**
+ * Master public key of the exchange.
+ */
+ val masterPublicKey: String,
+
+ /**
+ * Auditors (partially) auditing the exchange.
+ */
+ val auditors: List<Auditor>,
+
+ /**
+ * Currency that the exchange offers.
+ */
+ val currency: String,
+
+ /**
+ * Last observed protocol version.
+ */
+ val protocolVersion: String,
+
+ /**
+ * Signing keys we got from the exchange, can also contain
+ * older signing keys that are not returned by /keys anymore.
+ */
+ val signingKeys: List<SigningKey>,
+
+ /**
+ * Timestamp for last update.
+ */
+ val lastUpdateTime: Timestamp
+)
+
+data class ExchangeWireInfo(
+ val feesForType: Map<String, List<WireFee>>,
+ val accounts: List<ExchangeBankAccount>
+)
+
+// TODO is this class needed?
+data class ExchangeBankAccount(
+ val paytoUri: String
+)
+
+sealed class ExchangeUpdateStatus(val value: String) {
+ object FetchKeys : ExchangeUpdateStatus("fetch-keys")
+ object FetchWire : ExchangeUpdateStatus("fetch-wire")
+ object FetchTerms : ExchangeUpdateStatus("fetch-terms")
+ object FinalizeUpdate : ExchangeUpdateStatus("finalize-update")
+ object Finished : ExchangeUpdateStatus("finished")
+}
+
+sealed class ExchangeUpdateReason(val value: String) {
+ object Initial : ExchangeUpdateReason("initial")
+ object Forced : ExchangeUpdateReason("forced")
+ object Scheduled : ExchangeUpdateReason("scheduled")
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Keys.kt b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Keys.kt
new file mode 100644
index 0000000..12b29db
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Keys.kt
@@ -0,0 +1,97 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.lib.wallet.exchange
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import kotlinx.serialization.Serializable
+import net.taler.lib.common.Timestamp
+
+/**
+ * Structure that the exchange gives us in /keys.
+ */
+@Serializable
+internal data class Keys(
+ /**
+ * List of offered denominations.
+ */
+ val denoms: List<Denomination>,
+
+ /**
+ * The exchange's master public key.
+ */
+ val master_public_key: String,
+
+ /**
+ * The list of auditors (partially) auditing the exchange.
+ */
+ val auditors: List<Auditor>,
+
+ /**
+ * Timestamp when this response was issued.
+ */
+ val list_issue_date: Timestamp,
+
+ /**
+ * List of revoked denominations.
+ */
+ val recoup: List<Recoup>?,
+
+ /**
+ * Short-lived signing keys used to sign online
+ * responses.
+ */
+ val signkeys: List<SigningKey>,
+
+ /**
+ * Protocol version.
+ */
+ val version: String
+) {
+ companion object {
+ /**
+ * Fetch an exchange's /keys with the given normalized base URL.
+ */
+ suspend fun fetch(httpClient: HttpClient, exchangeBaseUrl: String): Keys {
+ return httpClient.get("${exchangeBaseUrl}keys")
+ }
+ }
+}
+
+/**
+ * Structure of one exchange signing key in the /keys response.
+ */
+@Serializable
+data class SigningKey(
+ val stamp_start: Timestamp,
+ val stamp_expire: Timestamp,
+ val stamp_end: Timestamp,
+ val key: String,
+ val master_sig: String
+)
+
+/**
+ * Element of the payback list that the
+ * exchange gives us in /keys.
+ */
+@Serializable
+data class Recoup(
+ /**
+ * The hash of the denomination public key for which the payback is offered.
+ */
+ val h_denom_pub: String
+)
diff --git a/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Wire.kt b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Wire.kt
new file mode 100644
index 0000000..0dca4dd
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Wire.kt
@@ -0,0 +1,79 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2020 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.lib.wallet.exchange
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.taler.lib.common.Amount
+import net.taler.lib.common.Timestamp
+
+@Serializable
+internal data class Wire(
+ val accounts: List<AccountInfo>,
+ val fees: Map<String, List<WireFee>>
+) {
+ companion object {
+ /**
+ * Fetch an exchange's /wire with the given normalized base URL.
+ */
+ suspend fun fetch(httpClient: HttpClient, exchangeBaseUrl: String): Wire {
+ return httpClient.get("${exchangeBaseUrl}wire")
+ }
+ }
+}
+
+@Serializable
+data class AccountInfo(
+ @SerialName("payto_uri")
+ val paytoUri: String,
+ @SerialName("master_sig")
+ val masterSig: String
+)
+
+/**
+ * Wire fees as announced by the exchange.
+ */
+@Serializable
+data class WireFee(
+ /**
+ * Fee for wire transfers.
+ */
+ @SerialName("wire_fee")
+ val wireFee: Amount,
+ /**
+ * Fees to close and refund a reserve.
+ */
+ @SerialName("closing_fee")
+ val closingFee: Amount,
+ /**
+ * Start date of the fee.
+ */
+ @SerialName("start_date")
+ val startStamp: Timestamp,
+ /**
+ * End date of the fee.
+ */
+ @SerialName("end_date")
+ val endStamp: Timestamp,
+ /**
+ * Signature made by the exchange master key.
+ */
+ @SerialName("sig")
+ val signature: String
+)