summaryrefslogtreecommitdiff
path: root/wallet/src/commonMain/kotlin
diff options
context:
space:
mode:
Diffstat (limited to 'wallet/src/commonMain/kotlin')
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt210
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Base32Crockford.kt129
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Db.kt89
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/PaytoUri.kt45
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/TalerUri.kt60
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Time.kt85
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Types.kt96
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Utils.kt41
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Version.kt78
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/WalletApi.kt101
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Crypto.kt78
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt55
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Deposit.kt110
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Kdf.kt90
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Planchet.kt87
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Recoup.kt83
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Refresh.kt265
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Signature.kt155
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Auditor.kt56
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Denomination.kt234
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Exchange.kt262
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/ExchangeRecord.kt151
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Keys.kt97
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Wire.kt79
-rw-r--r--wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/operations/Withdraw.kt305
25 files changed, 3041 insertions, 0 deletions
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt
new file mode 100644
index 0000000..2d39bb3
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt
@@ -0,0 +1,210 @@
+/*
+ * 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.wallet.kotlin
+
+import kotlinx.serialization.Decoder
+import kotlinx.serialization.Encoder
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Serializer
+import net.taler.wallet.kotlin.crypto.CryptoImpl.Companion.toByteArray
+import kotlin.math.floor
+import kotlin.math.pow
+import kotlin.math.roundToInt
+
+class AmountParserException(msg: String? = null, cause: Throwable? = null) : Exception(msg, cause)
+class AmountOverflowException(msg: String? = null, cause: Throwable? = null) : Exception(msg, cause)
+
+@Serializable
+data class Amount(
+ /**
+ * name of the currency using either a three-character ISO 4217 currency code,
+ * or a regional currency identifier starting with a "*" followed by at most 10 characters.
+ * ISO 4217 exponents in the name are not supported,
+ * although the "fraction" is corresponds to an ISO 4217 exponent of 6.
+ */
+ val currency: String,
+
+ /**
+ * The integer part may be at most 2^52.
+ * Note that "1" here would correspond to 1 EUR or 1 USD, depending on currency, not 1 cent.
+ */
+ val value: Long,
+
+ /**
+ * Unsigned 32 bit fractional value to be added to value representing
+ * an additional currency fraction, in units of one hundred millionth (1e-8)
+ * of the base currency value. For example, a fraction
+ * of 50_000_000 would correspond to 50 cents.
+ */
+ val fraction: Int
+) : Comparable<Amount> {
+
+ @Serializer(forClass = Amount::class)
+ companion object : KSerializer<Amount> {
+
+ private const val FRACTIONAL_BASE: Int = 100000000 // 1e8
+
+ @Suppress("unused")
+ private val REGEX = Regex("""^[-_*A-Za-z0-9]{1,12}:([0-9]+)\.?([0-9]+)?$""")
+ private val REGEX_CURRENCY = Regex("""^[-_*A-Za-z0-9]{1,12}$""")
+ internal val MAX_VALUE = 2.0.pow(52).toLong()
+ private const val MAX_FRACTION_LENGTH = 8
+ internal const val MAX_FRACTION = 99_999_999
+
+ fun zero(currency: String): Amount {
+ return Amount(checkCurrency(currency), 0, 0)
+ }
+
+ fun fromJSONString(str: String): Amount {
+ val split = str.split(":")
+ if (split.size != 2) throw AmountParserException("Invalid Amount Format")
+ return fromString(split[0], split[1])
+ }
+
+ fun fromString(currency: String, str: String): Amount {
+ // value
+ val valueSplit = str.split(".")
+ val value = checkValue(valueSplit[0].toLongOrNull())
+ // fraction
+ val fraction: Int = if (valueSplit.size > 1) {
+ val fractionStr = valueSplit[1]
+ if (fractionStr.length > MAX_FRACTION_LENGTH)
+ throw AmountParserException("Fraction $fractionStr too long")
+ val fraction = "0.$fractionStr".toDoubleOrNull()
+ ?.times(FRACTIONAL_BASE)
+ ?.roundToInt()
+ checkFraction(fraction)
+ } else 0
+ return Amount(checkCurrency(currency), value, fraction)
+ }
+
+ fun min(currency: String): Amount = Amount(currency, 0, 1)
+ fun max(currency: String): Amount = Amount(currency, MAX_VALUE, MAX_FRACTION)
+
+// fun fromJsonObject(json: JSONObject): Amount {
+// val currency = checkCurrency(json.optString("currency"))
+// val value = checkValue(json.optString("value").toLongOrNull())
+// val fraction = checkFraction(json.optString("fraction").toIntOrNull())
+// return Amount(currency, value, fraction)
+// }
+
+ private fun checkCurrency(currency: String): String {
+ if (!REGEX_CURRENCY.matches(currency))
+ throw AmountParserException("Invalid currency: $currency")
+ return currency
+ }
+
+ private fun checkValue(value: Long?): Long {
+ if (value == null || value > MAX_VALUE)
+ throw AmountParserException("Value $value greater than $MAX_VALUE")
+ return value
+ }
+
+ private fun checkFraction(fraction: Int?): Int {
+ if (fraction == null || fraction > MAX_FRACTION)
+ throw AmountParserException("Fraction $fraction greater than $MAX_FRACTION")
+ return fraction
+ }
+
+ override fun serialize(encoder: Encoder, value: Amount) {
+ encoder.encodeString(value.toJSONString())
+ }
+
+ override fun deserialize(decoder: Decoder): Amount {
+ return fromJSONString(decoder.decodeString())
+ }
+ }
+
+ val amountStr: String
+ get() = if (fraction == 0) "$value" else {
+ var f = fraction
+ var fractionStr = ""
+ while (f > 0) {
+ fractionStr += f / (FRACTIONAL_BASE / 10)
+ f = (f * 10) % FRACTIONAL_BASE
+ }
+ "$value.$fractionStr"
+ }
+
+ operator fun plus(other: Amount): Amount {
+ check(currency == other.currency) { "Can only subtract from same currency" }
+ val resultValue = value + other.value + floor((fraction + other.fraction).toDouble() / FRACTIONAL_BASE).toLong()
+ if (resultValue > MAX_VALUE)
+ throw AmountOverflowException()
+ val resultFraction = (fraction + other.fraction) % FRACTIONAL_BASE
+ return Amount(currency, resultValue, resultFraction)
+ }
+
+ operator fun times(factor: Int): Amount {
+ // TODO consider replacing with a faster implementation
+ if (factor == 0) return zero(currency)
+ var result = this
+ for (i in 1 until factor) result += this
+ return result
+ }
+
+ operator fun minus(other: Amount): Amount {
+ check(currency == other.currency) { "Can only subtract from same currency" }
+ var resultValue = value
+ var resultFraction = fraction
+ if (resultFraction < other.fraction) {
+ if (resultValue < 1L)
+ throw AmountOverflowException()
+ resultValue--
+ resultFraction += FRACTIONAL_BASE
+ }
+ check(resultFraction >= other.fraction)
+ resultFraction -= other.fraction
+ if (resultValue < other.value)
+ throw AmountOverflowException()
+ resultValue -= other.value
+ return Amount(currency, resultValue, resultFraction)
+ }
+
+ fun isZero(): Boolean {
+ return value == 0L && fraction == 0
+ }
+
+ fun toJSONString(): String {
+ return "$currency:$amountStr"
+ }
+
+ fun toByteArray() = ByteArray(8 + 4 + 12).apply {
+ value.toByteArray().copyInto(this, 0, 0, 8)
+ fraction.toByteArray().copyInto(this, 8, 0, 4)
+ currency.encodeToByteArray().copyInto(this, 12)
+ }
+
+ override fun toString(): String {
+ return "$amountStr $currency"
+ }
+
+ override fun compareTo(other: Amount): Int {
+ check(currency == other.currency) { "Can only compare amounts with the same currency" }
+ when {
+ value == other.value -> {
+ if (fraction < other.fraction) return -1
+ if (fraction > other.fraction) return 1
+ return 0
+ }
+ value < other.value -> return -1
+ else -> return 1
+ }
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Base32Crockford.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Base32Crockford.kt
new file mode 100644
index 0000000..9043731
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Base32Crockford.kt
@@ -0,0 +1,129 @@
+/*
+ * 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.wallet.kotlin
+
+
+class EncodingException : Exception("Invalid encoding")
+
+
+object Base32Crockford {
+
+ private fun ByteArray.getIntAt(index: Int): Int {
+ val x = this[index].toInt()
+ return if (x >= 0) x else (x + 256)
+ }
+
+ private var encTable = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+
+ fun encode(data: ByteArray): String {
+ val sb = StringBuilder()
+ val size = data.size
+ var bitBuf = 0
+ var numBits = 0
+ var pos = 0
+ while (pos < size || numBits > 0) {
+ if (pos < size && numBits < 5) {
+ val d = data.getIntAt(pos++)
+ bitBuf = (bitBuf shl 8) or d
+ numBits += 8
+ }
+ if (numBits < 5) {
+ // zero-padding
+ bitBuf = bitBuf shl (5 - numBits)
+ numBits = 5
+ }
+ val v = bitBuf.ushr(numBits - 5) and 31
+ sb.append(encTable[v])
+ numBits -= 5
+ }
+ return sb.toString()
+ }
+
+ fun decode(encoded: String): ByteArray {
+ val size = encoded.length
+ var bitpos = 0
+ var bitbuf = 0
+ var readPosition = 0
+ var writePosition = 0
+ val out = ByteArray(calculateDecodedDataLength(size))
+
+ while (readPosition < size || bitpos > 0) {
+ //println("at position $readPosition with bitpos $bitpos")
+ if (readPosition < size) {
+ val v = getValue(encoded[readPosition++])
+ bitbuf = (bitbuf shl 5) or v
+ bitpos += 5
+ }
+ while (bitpos >= 8) {
+ val d = (bitbuf ushr (bitpos - 8)) and 0xFF
+ out[writePosition] = d.toByte()
+ writePosition++
+ bitpos -= 8
+ }
+ if (readPosition == size && bitpos > 0) {
+ bitbuf = (bitbuf shl (8 - bitpos)) and 0xFF
+ bitpos = if (bitbuf == 0) 0 else 8
+ }
+ }
+ return out
+ }
+
+ private fun getValue(chr: Char): Int {
+ var a = chr
+ when (a) {
+ 'O', 'o' -> a = '0'
+ 'i', 'I', 'l', 'L' -> a = '1'
+ 'u', 'U' -> a = 'V'
+ }
+ if (a in '0'..'9')
+ return a - '0'
+ if (a in 'a'..'z')
+ a = a.toUpperCase()
+ var dec = 0
+ if (a in 'A'..'Z') {
+ if ('I' < a) dec++
+ if ('L' < a) dec++
+ if ('O' < a) dec++
+ if ('U' < a) dec++
+ return a - 'A' + 10 - dec
+ }
+ throw EncodingException()
+ }
+
+ /**
+ * Compute the length of the resulting string when encoding data of the given size
+ * in bytes.
+ *
+ * @param dataSize size of the data to encode in bytes
+ * @return size of the string that would result from encoding
+ */
+ private fun calculateEncodedStringLength(dataSize: Int): Int {
+ return (dataSize * 8 + 4) / 5
+ }
+
+ /**
+ * Compute the length of the resulting data in bytes when decoding a (valid) string of the
+ * given size.
+ *
+ * @param stringSize size of the string to decode
+ * @return size of the resulting data in bytes
+ */
+ fun calculateDecodedDataLength(stringSize: Int): Int {
+ return stringSize * 5 / 8
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Db.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Db.kt
new file mode 100644
index 0000000..3a5ecd6
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Db.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.wallet.kotlin
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import net.taler.wallet.kotlin.exchange.DenominationRecord
+import net.taler.wallet.kotlin.exchange.ExchangeRecord
+
+internal interface Db {
+ suspend fun put(exchange: ExchangeRecord)
+ suspend fun listExchanges(): List<ExchangeRecord>
+ suspend fun getExchangeByBaseUrl(baseUrl: String): ExchangeRecord?
+ suspend fun deleteExchangeByBaseUrl(baseUrl: String)
+ suspend fun put(denomination: DenominationRecord)
+ suspend fun getDenominationsByBaseUrl(baseUrl: String): List<DenominationRecord>
+ suspend fun <T> transaction(function: suspend Db.() -> T): T
+}
+
+internal expect class DbFactory() {
+ fun openDb(): Db
+}
+
+internal class FakeDb : Db {
+
+ private data class Data(
+ val exchanges: HashMap<String, ExchangeRecord> = HashMap(),
+ val denominations: HashMap<String, ArrayList<DenominationRecord>> = HashMap()
+ )
+
+ private var data = Data()
+ private val mutex = Mutex(false)
+
+ override suspend fun put(exchange: ExchangeRecord) {
+ data.exchanges[exchange.baseUrl] = exchange
+ }
+
+ override suspend fun listExchanges(): List<ExchangeRecord> {
+ return data.exchanges.values.toList()
+ }
+
+ override suspend fun getExchangeByBaseUrl(baseUrl: String): ExchangeRecord? {
+ return data.exchanges[baseUrl]
+ }
+
+ override suspend fun deleteExchangeByBaseUrl(baseUrl: String) {
+ data.exchanges.remove(baseUrl)
+ }
+
+ override suspend fun put(denomination: DenominationRecord): Unit = mutex.withLock("transaction") {
+ val list = data.denominations[denomination.exchangeBaseUrl] ?: {
+ val newList = ArrayList<DenominationRecord>()
+ data.denominations[denomination.exchangeBaseUrl] = newList
+ newList
+ }()
+ val index = list.indexOfFirst { it.denomPub == denomination.denomPub }
+ if (index == -1) list.add(denomination)
+ else list[index] = denomination
+ }
+
+ override suspend fun getDenominationsByBaseUrl(baseUrl: String): List<DenominationRecord> {
+ return data.denominations[baseUrl] ?: emptyList()
+ }
+
+ override suspend fun <T> transaction(function: suspend Db.() -> T): T = mutex.withLock("transaction") {
+ val dataCopy = data.copy()
+ return@withLock try {
+ function()
+ } catch (e: Throwable) {
+ data = dataCopy
+ throw e
+ }
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/PaytoUri.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/PaytoUri.kt
new file mode 100644
index 0000000..f6b11d2
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/PaytoUri.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.wallet.kotlin
+
+data class PaytoUri(
+ val targetType: String,
+ val targetPath: String,
+ val params: Map<String, String>
+) {
+ companion object {
+ private const val SCHEMA = "payto://"
+ fun fromString(s: String): PaytoUri? {
+ if (!s.startsWith(SCHEMA)) return null
+ val rest = s.slice(SCHEMA.length until s.length).split('?')
+ val account = rest[0]
+ val query = if (rest.size > 1) rest[1] else null
+ val firstSlashPos = account.indexOf('/')
+ if (firstSlashPos == -1) return null
+ return PaytoUri(
+ targetType = account.slice(0 until firstSlashPos),
+ targetPath = account.slice((firstSlashPos + 1) until account.length),
+ params = HashMap<String, String>().apply {
+ query?.split('&')?.forEach {
+ val field = it.split('=')
+ if (field.size > 1) put(field[0], field[1])
+ }
+ }
+ )
+ } // end fromString()
+ }
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/TalerUri.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/TalerUri.kt
new file mode 100644
index 0000000..c489d71
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/TalerUri.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.wallet.kotlin
+
+internal object TalerUri {
+
+ private const val SCHEME = "taler://"
+ private const val SCHEME_INSECURE = "taler+http://"
+ private const val AUTHORITY_PAY = "pay"
+ private const val AUTHORITY_WITHDRAW = "withdraw"
+ private const val AUTHORITY_REFUND = "refund"
+ private const val AUTHORITY_TIP = "tip"
+
+ data class WithdrawUriResult(
+ val bankIntegrationApiBaseUrl: String,
+ val withdrawalOperationId: String
+ )
+
+ /**
+ * Parses a withdraw URI and returns a bank status URL or null if the URI was invalid.
+ */
+ fun parseWithdrawUri(uri: String): WithdrawUriResult? {
+ val (resultScheme, prefix) = when {
+ uri.startsWith(SCHEME, ignoreCase = true) -> {
+ Pair("https://", "${SCHEME}${AUTHORITY_WITHDRAW}/")
+ }
+ uri.startsWith(SCHEME_INSECURE, ignoreCase = true) -> {
+ Pair("http://", "${SCHEME_INSECURE}${AUTHORITY_WITHDRAW}/")
+ }
+ else -> return null
+ }
+ if (!uri.startsWith(prefix)) return null
+ val parts = uri.let {
+ (if (it.endsWith("/")) it.dropLast(1) else it).substring(prefix.length).split('/')
+ }
+ if (parts.size < 2) return null
+ val host = parts[0].toLowerCase()
+ val pathSegments = parts.slice(1 until parts.size - 1).joinToString("/")
+ val withdrawId = parts.last()
+ if (withdrawId.isBlank()) return null
+ val url = "${resultScheme}${host}/${pathSegments}"
+
+ return WithdrawUriResult(url, withdrawId)
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Time.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Time.kt
new file mode 100644
index 0000000..4143389
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Time.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.wallet.kotlin
+
+import com.soywiz.klock.DateTime
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.taler.wallet.kotlin.Duration.Companion.FOREVER
+import net.taler.wallet.kotlin.crypto.CryptoImpl.Companion.toByteArray
+import kotlin.math.max
+
+@Serializable
+data class Timestamp(
+ @SerialName("t_ms")
+ val ms: Long
+) : Comparable<Timestamp> {
+
+ companion object {
+ const val NEVER: Long = -1 // TODO or UINT64_MAX?
+ fun now(): Timestamp = Timestamp(DateTime.now().unixMillisLong)
+ }
+
+ /**
+ * Returns a copy of this [Timestamp] rounded to seconds.
+ */
+ fun truncateSeconds(): Timestamp {
+ if (ms == NEVER) return Timestamp(ms)
+ return Timestamp((ms / 1000L) * 1000L)
+ }
+
+ fun roundedToByteArray(): ByteArray = ByteArray(8).apply {
+ (truncateSeconds().ms * 1000L).toByteArray().copyInto(this)
+ }
+
+ operator fun minus(other: Timestamp): Duration = when {
+ ms == NEVER -> Duration(FOREVER)
+ other.ms == NEVER -> throw Error("Invalid argument for timestamp comparision")
+ ms < other.ms -> Duration(0)
+ else -> Duration(ms - other.ms)
+ }
+
+ operator fun minus(other: Duration): Timestamp = when {
+ ms == NEVER -> this
+ other.ms == FOREVER -> Timestamp(0)
+ else -> Timestamp(max(0, ms - other.ms))
+ }
+
+ override fun compareTo(other: Timestamp): Int {
+ return if (ms == NEVER) {
+ if (other.ms == NEVER) 0
+ else 1
+ } else {
+ if (other.ms == NEVER) -1
+ else ms.compareTo(other.ms)
+ }
+ }
+
+}
+
+@Serializable
+data class Duration(
+ /**
+ * Duration in milliseconds.
+ */
+ @SerialName("d_ms")
+ val ms: Long
+) {
+ companion object {
+ const val FOREVER: Long = -1 // TODO or UINT64_MAX?
+ }
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Types.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Types.kt
new file mode 100644
index 0000000..04b17e7
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Types.kt
@@ -0,0 +1,96 @@
+/*
+ * 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.wallet.kotlin
+
+
+class CoinRecord(
+ /**
+ * Where did the coin come from? Used for recouping coins.
+ */
+ val coinSource: CoinSourceType,
+
+ /**
+ * Public key of the coin.
+ */
+ val coinPub: String,
+
+ /**
+ * Private key to authorize operations on the coin.
+ */
+ val coinPriv: String,
+
+ /**
+ * Key used by the exchange used to sign the coin.
+ */
+ val denomPub: String,
+
+ /**
+ * Hash of the public key that signs the coin.
+ */
+ val denomPubHash: String,
+
+ /**
+ * Unblinded signature by the exchange.
+ */
+ val denomSig: String,
+
+ /**
+ * Amount that's left on the coin.
+ */
+ val currentAmount: Amount,
+
+ /**
+ * Base URL that identifies the exchange from which we got the coin.
+ */
+ val exchangeBaseUrl: String,
+
+ /**
+ * The coin is currently suspended, and will not be used for payments.
+ */
+ val suspended: Boolean,
+
+ /**
+ * Blinding key used when withdrawing the coin.
+ * Potentially send again during payback.
+ */
+ val blindingKey: String,
+
+ /**
+ * Status of the coin.
+ */
+ val status: CoinStatus
+)
+
+enum class CoinSourceType(val value: String) {
+ WITHDRAW("withdraw"),
+ REFRESH("refresh"),
+ TIP("tip")
+}
+
+enum class CoinStatus(val value: String) {
+
+ /**
+ * Withdrawn and never shown to anybody.
+ */
+ FRESH("fresh"),
+
+ /**
+ * A coin that has been spent and refreshed.
+ */
+ DORMANT("dormant")
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Utils.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Utils.kt
new file mode 100644
index 0000000..2549195
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Utils.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.wallet.kotlin
+
+import io.ktor.client.HttpClient
+import io.ktor.client.features.json.JsonFeature
+import io.ktor.client.features.json.serializer.KotlinxSerializer
+import io.ktor.client.features.logging.LogLevel
+import io.ktor.client.features.logging.Logging
+import kotlinx.serialization.UnstableDefault
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonConfiguration
+
+fun getDefaultHttpClient(): HttpClient = HttpClient {
+ install(JsonFeature) {
+ serializer = KotlinxSerializer(Json(getJsonConfiguration()))
+ }
+ install(Logging) {
+// level = LogLevel.HEADERS
+ level = LogLevel.NONE
+ }
+}
+
+@OptIn(UnstableDefault::class)
+internal fun getJsonConfiguration() = JsonConfiguration(
+ ignoreUnknownKeys = true
+)
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Version.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Version.kt
new file mode 100644
index 0000000..45e7840
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Version.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.wallet.kotlin
+
+import kotlin.math.sign
+
+/**
+ * Semantic versioning, but libtool-style.
+ * See https://www.gnu.org/software/libtool/manual/html_node/Libtool-versioning.html
+ */
+
+/**
+ * Result of comparing two libtool versions.
+ */
+data class VersionMatchResult(
+ /**
+ * Is the first version compatible with the second?
+ */
+ val compatible: Boolean,
+ /**
+ * Is the first version older (-1), newer (+1) or identical (0)?
+ */
+ val currentCmp: Int
+)
+
+data class Version(
+ val current: Int,
+ val revision: Int,
+ val age: Int
+)
+
+/**
+ * Compare two libtool-style version strings.
+ */
+fun compareVersions(me: String,other: String): VersionMatchResult? {
+ val meVer = parseVersion (me)
+ val otherVer = parseVersion (other)
+ if (meVer == null || otherVer == null) return null
+
+ val compatible = meVer.current - meVer.age <= otherVer.current &&
+ meVer.current >= otherVer.current - otherVer.age
+
+ val currentCmp = sign((meVer.current - otherVer.current).toDouble()).toInt()
+
+ return VersionMatchResult(compatible, currentCmp)
+}
+
+fun parseVersion(v: String): Version? {
+ val elements = v.split(":")
+ if (elements.size != 3) return null
+ val (currentStr, revisionStr, ageStr) = elements
+ val current = currentStr.toIntOrNull()
+ val revision = revisionStr.toIntOrNull()
+ val age = ageStr.toIntOrNull()
+ if (current == null || revision == null || age == null) return null
+ return Version(current, revision, age)
+}
+
+class SupportedVersions(
+ val walletVersion: Version,
+ val exchangeVersion: Version,
+ val bankVersion: Version,
+ val merchantVersion: Version
+)
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/WalletApi.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/WalletApi.kt
new file mode 100644
index 0000000..11fd181
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/WalletApi.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.wallet.kotlin
+
+import io.ktor.client.HttpClient
+import net.taler.wallet.kotlin.crypto.Crypto
+import net.taler.wallet.kotlin.crypto.CryptoFactory
+import net.taler.wallet.kotlin.crypto.Signature
+import net.taler.wallet.kotlin.exchange.Exchange
+import net.taler.wallet.kotlin.exchange.ExchangeListItem
+import net.taler.wallet.kotlin.exchange.GetExchangeTosResult
+import net.taler.wallet.kotlin.operations.Withdraw
+import net.taler.wallet.kotlin.operations.WithdrawalDetails
+import net.taler.wallet.kotlin.operations.WithdrawalDetailsForUri
+
+public class WalletApi {
+
+ private val httpClient: HttpClient = getDefaultHttpClient()
+ private val db: Db = DbFactory().openDb()
+ private val crypto: Crypto = CryptoFactory.getCrypto()
+ private val signature: Signature = Signature(crypto)
+ private val exchangeManager: Exchange = Exchange(crypto, signature, httpClient, db = db)
+ private val withdrawManager = Withdraw(httpClient, db, crypto, signature, exchangeManager)
+
+ public fun getVersions(): SupportedVersions {
+ return SupportedVersions(
+ walletVersion = Version(8, 0, 0),
+ exchangeVersion = Version(8, 0, 0),
+ bankVersion = Version(0, 0, 0),
+ merchantVersion = Version(1, 0, 0)
+ )
+ }
+
+ public suspend fun getWithdrawalDetailsForUri(talerWithdrawUri: String): WithdrawalDetailsForUri {
+ val bankInfo = withdrawManager.getBankInfo(talerWithdrawUri)
+ return WithdrawalDetailsForUri(
+ amount = bankInfo.amount,
+ defaultExchangeBaseUrl = bankInfo.suggestedExchange,
+ possibleExchanges = emptyList()
+ )
+ }
+
+ public suspend fun getWithdrawalDetailsForAmount(
+ exchangeBaseUrl: String,
+ amount: Amount
+ ): WithdrawalDetails {
+ val details = withdrawManager.getWithdrawalDetails(exchangeBaseUrl, amount)
+ return WithdrawalDetails(
+ tosAccepted = details.exchange.termsOfServiceAccepted,
+ amountRaw = amount,
+ amountEffective = amount - details.overhead - details.withdrawFee
+ )
+ }
+
+ public suspend fun listExchanges(): List<ExchangeListItem> {
+ return db.listExchanges().mapNotNull { exchange ->
+ ExchangeListItem.fromExchangeRecord(exchange)
+ }
+ }
+
+ public suspend fun addExchange(exchangeBaseUrl: String): ExchangeListItem {
+ val exchange = exchangeManager.updateFromUrl(exchangeBaseUrl)
+ db.put(exchange)
+ return ExchangeListItem.fromExchangeRecord(exchange) ?: TODO("error handling")
+ }
+
+ public suspend fun getExchangeTos(exchangeBaseUrl: String): GetExchangeTosResult {
+ val record = db.getExchangeByBaseUrl(exchangeBaseUrl) ?: TODO("error handling")
+ return GetExchangeTosResult(
+ tos = record.termsOfServiceText ?: TODO("error handling"),
+ currentEtag = record.termsOfServiceLastEtag ?: TODO("error handling"),
+ acceptedEtag = record.termsOfServiceAcceptedEtag
+ )
+ }
+
+ public suspend fun setExchangeTosAccepted(exchangeBaseUrl: String, acceptedEtag: String) {
+ db.transaction {
+ val record = getExchangeByBaseUrl(exchangeBaseUrl) ?: TODO("error handling")
+ val updatedRecord = record.copy(
+ termsOfServiceAcceptedEtag = acceptedEtag,
+ termsOfServiceAcceptedTimestamp = Timestamp.now()
+ )
+ put(updatedRecord)
+ }
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Crypto.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Crypto.kt
new file mode 100644
index 0000000..226aa64
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Crypto.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.wallet.kotlin.crypto
+
+internal interface Crypto {
+ fun sha256(input: ByteArray): ByteArray
+ fun sha512(input: ByteArray): ByteArray
+ fun getHashSha512State(): HashSha512State
+ fun getRandomBytes(num: Int): ByteArray
+ fun eddsaGetPublic(eddsaPrivateKey: ByteArray): ByteArray
+ fun ecdheGetPublic(ecdhePrivateKey: ByteArray): ByteArray
+ fun createEddsaKeyPair(): EddsaKeyPair
+ fun createEcdheKeyPair(): EcdheKeyPair
+ fun eddsaSign(msg: ByteArray, eddsaPrivateKey: ByteArray): ByteArray
+ fun eddsaVerify(msg: ByteArray, sig: ByteArray, eddsaPub: ByteArray): Boolean
+ fun keyExchangeEddsaEcdhe(eddsaPrivateKey: ByteArray, ecdhePublicKey: ByteArray): ByteArray
+ fun keyExchangeEcdheEddsa(ecdhePrivateKey: ByteArray, eddsaPublicKey: ByteArray): ByteArray
+ fun kdf(outputLength: Int, ikm: ByteArray, salt: ByteArray, info: ByteArray): ByteArray
+ fun rsaBlind(hm: ByteArray, bks: ByteArray, rsaPubEnc: ByteArray): ByteArray
+ fun rsaUnblind(sig: ByteArray, rsaPubEnc: ByteArray, bks: ByteArray): ByteArray
+ fun rsaVerify(hm: ByteArray, rsaSig: ByteArray, rsaPubEnc: ByteArray): Boolean
+ fun setupRefreshPlanchet(secretSeed: ByteArray, coinNumber: Int): FreshCoin
+}
+
+interface HashSha512State {
+ fun update(data: ByteArray): HashSha512State
+ fun final(): ByteArray
+}
+class EddsaKeyPair(val privateKey: ByteArray, val publicKey: ByteArray)
+class EcdheKeyPair(val privateKey: ByteArray, val publicKey: ByteArray)
+data class FreshCoin(val coinPublicKey: ByteArray, val coinPrivateKey: ByteArray, val bks: ByteArray) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+ other as FreshCoin
+ if (!coinPublicKey.contentEquals(other.coinPublicKey)) return false
+ if (!coinPrivateKey.contentEquals(other.coinPrivateKey)) return false
+ if (!bks.contentEquals(other.bks)) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = coinPublicKey.contentHashCode()
+ result = 31 * result + coinPrivateKey.contentHashCode()
+ result = 31 * result + bks.contentHashCode()
+ return result
+ }
+}
+
+internal expect object CryptoFactory {
+ internal fun getCrypto(): Crypto
+}
+
+private val hexArray = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
+
+fun ByteArray.toHexString(): String {
+ val hexChars = CharArray(this.size * 2)
+ for (j in this.indices) {
+ val v = (this[j].toInt() and 0xFF)
+ hexChars[j * 2] = hexArray[v ushr 4]
+ hexChars[j * 2 + 1] = hexArray[v and 0x0F]
+ }
+ return String(hexChars)
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt
new file mode 100644
index 0000000..0780e45
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.wallet.kotlin.crypto
+
+import net.taler.wallet.kotlin.crypto.CryptoImpl.Companion.toByteArray
+
+abstract class CryptoImpl : Crypto {
+
+ companion object {
+ fun Int.toByteArray(): ByteArray {
+ val bytes = ByteArray(4)
+ bytes[3] = (this and 0xFFFF).toByte()
+ bytes[2] = ((this ushr 8) and 0xFFFF).toByte()
+ bytes[1] = ((this ushr 16) and 0xFFFF).toByte()
+ bytes[0] = ((this ushr 24) and 0xFFFF).toByte()
+ return bytes
+ }
+
+ fun Long.toByteArray() = ByteArray(8).apply {
+ var l = this@toByteArray
+ for (i in 7 downTo 0) {
+ this[i] = (l and 0xFF).toByte()
+ l = l shr 8
+ }
+ }
+ }
+
+ override fun kdf(outputLength: Int, ikm: ByteArray, salt: ByteArray, info: ByteArray): ByteArray {
+ return Kdf.kdf(outputLength, ikm, salt, info, { sha256(it) }, { sha512(it) })
+ }
+
+ override fun setupRefreshPlanchet(secretSeed: ByteArray, coinNumber: Int): FreshCoin {
+ val info = "taler-coin-derivation".encodeToByteArray()
+ val salt = coinNumber.toByteArray()
+ val out = kdf(64, secretSeed, salt, info)
+ val coinPrivateKey = out.copyOfRange(0, 32)
+ val bks = out.copyOfRange(32, 64)
+ return FreshCoin(eddsaGetPublic(coinPrivateKey), coinPrivateKey, bks)
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Deposit.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Deposit.kt
new file mode 100644
index 0000000..3156d3f
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Deposit.kt
@@ -0,0 +1,110 @@
+/*
+ * 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.wallet.kotlin.crypto
+
+import net.taler.wallet.kotlin.Amount
+import net.taler.wallet.kotlin.Base32Crockford
+import net.taler.wallet.kotlin.Timestamp
+import net.taler.wallet.kotlin.crypto.Signature.Companion.WALLET_COIN_DEPOSIT
+
+/**
+ * Deposit operations are requested by a merchant during a transaction.
+ * For the deposit operation, the merchant has to obtain the deposit permission for a coin
+ * from their customer who owns the coin.
+ *
+ * When depositing a coin, the merchant is credited an amount specified in the deposit permission,
+ * possibly a fraction of the total coin’s value,
+ * minus the deposit fee as specified by the coin’s denomination.
+ */
+internal class Deposit(private val crypto: Crypto) {
+
+ /**
+ * Private data required to make a deposit permission.
+ */
+ data class DepositInfo(
+ val exchangeBaseUrl: String,
+ val contractTermsHash: String,
+ val coinPublicKey: String,
+ val coinPrivateKey: String,
+ val spendAmount: Amount,
+ val timestamp: Timestamp,
+ val refundDeadline: Timestamp,
+ val merchantPublicKey: String,
+ val feeDeposit: Amount,
+ val wireInfoHash: String,
+ val denomPublicKey: String,
+ val denomSignature: String
+ )
+
+ /**
+ * Deposit permission for a single coin.
+ */
+ // TODO rename _
+ data class CoinDepositPermission(
+ /**
+ * Signature by the coin.
+ */
+ val coinSignature: String,
+ /**
+ * Public key of the coin being spend.
+ */
+ val coinPublicKey: String,
+ /**
+ * Signature made by the denomination public key.
+ */
+ val denomSignature: String,
+ /**
+ * The denomination public key associated with this coin.
+ */
+ val denomPublicKey: String,
+ /**
+ * The amount that is subtracted from this coin with this payment.
+ */
+ val contribution: String,
+ /**
+ * URL of the exchange this coin was withdrawn from.
+ */
+ val exchangeBaseUrl: String
+ )
+
+ /**
+ * Generate updated coins (to store in the database) and deposit permissions for each given coin.
+ */
+ fun signDepositPermission(depositInfo: DepositInfo): CoinDepositPermission {
+ val d = Signature.PurposeBuilder(WALLET_COIN_DEPOSIT)
+ .put(Base32Crockford.decode(depositInfo.contractTermsHash))
+ .put(Base32Crockford.decode(depositInfo.wireInfoHash))
+ .put(depositInfo.timestamp.roundedToByteArray())
+ .put(depositInfo.refundDeadline.roundedToByteArray())
+ .put(depositInfo.spendAmount.toByteArray())
+ .put(depositInfo.feeDeposit.toByteArray())
+ .put(Base32Crockford.decode(depositInfo.merchantPublicKey))
+ .put(Base32Crockford.decode(depositInfo.coinPublicKey))
+ .build()
+ val coinPriv = Base32Crockford.decode(depositInfo.coinPrivateKey);
+ val coinSig = crypto.eddsaSign(d, coinPriv)
+ return CoinDepositPermission(
+ coinPublicKey = depositInfo.coinPublicKey,
+ coinSignature = Base32Crockford.encode(coinSig),
+ contribution = depositInfo.spendAmount.toJSONString(),
+ denomPublicKey = depositInfo.denomPublicKey,
+ exchangeBaseUrl = depositInfo.exchangeBaseUrl,
+ denomSignature = depositInfo.denomSignature
+ )
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Kdf.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Kdf.kt
new file mode 100644
index 0000000..44f55cc
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Kdf.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.wallet.kotlin.crypto
+
+import kotlin.experimental.xor
+import kotlin.math.ceil
+
+internal object Kdf {
+
+ const val HMAC_SHA256_BLOCK_SIZE = 64
+ const val HMAC_SHA512_BLOCK_SIZE = 128
+
+ fun kdf(
+ outputLength: Int,
+ ikm: ByteArray,
+ salt: ByteArray,
+ info: ByteArray,
+ sha256: (ByteArray) -> ByteArray,
+ sha512: (ByteArray) -> ByteArray
+ ): ByteArray {
+ //extract
+ val prk = hmacSha512(salt, ikm, sha512)
+
+ // expand
+ val n = ceil(outputLength.toDouble() / 32).toInt()
+ val output = ByteArray(n * 32)
+ for (i in 0 until n) {
+ val buf: ByteArray
+ if (i == 0) {
+ buf = ByteArray(info.size + 1)
+ info.copyInto(buf)
+ } else {
+ buf = ByteArray(info.size + 1 + 32)
+ for (j in 0 until 32) {
+ buf[j] = output[(i - 1) * 32 + j]
+ }
+ info.copyInto(buf, destinationOffset = 32)
+ }
+ buf[buf.size - 1] = (i + 1).toByte()
+ val chunk = hmacSha256(prk, buf, sha256)
+ chunk.copyInto(output, destinationOffset = i * 32)
+ }
+ return output.copyOfRange(0, outputLength)
+ }
+
+ fun hmacSha256(key: ByteArray, message: ByteArray, sha256: (ByteArray) -> ByteArray): ByteArray {
+ return hmac(HMAC_SHA256_BLOCK_SIZE, key, message) { sha256(it) }
+ }
+
+ fun hmacSha512(key: ByteArray, message: ByteArray, sha512: (ByteArray) -> ByteArray): ByteArray {
+ return hmac(HMAC_SHA512_BLOCK_SIZE, key, message) { sha512(it) }
+ }
+
+ private fun hmac(blockSize: Int, key: ByteArray, message: ByteArray, hash: (ByteArray) -> ByteArray): ByteArray {
+ var newKey = key
+ if (newKey.size > blockSize) newKey = hash(newKey)
+ if (newKey.size < blockSize) newKey = ByteArray(blockSize).apply {
+ newKey.copyInto(this)
+ }
+ val okp = ByteArray(blockSize)
+ val ikp = ByteArray(blockSize)
+ for (i in 0 until blockSize) {
+ ikp[i] = newKey[i] xor 0x36
+ okp[i] = newKey[i] xor 0x5c
+ }
+ val b1 = ByteArray(blockSize + message.size)
+ ikp.copyInto(b1)
+ message.copyInto(b1, destinationOffset = blockSize)
+ val h0 = hash(b1)
+ val b2 = ByteArray(blockSize + h0.size)
+ okp.copyInto(b2)
+ h0.copyInto(b2, destinationOffset = blockSize)
+ return hash(b2)
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Planchet.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Planchet.kt
new file mode 100644
index 0000000..b29007e
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Planchet.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.wallet.kotlin.crypto
+
+import net.taler.wallet.kotlin.Amount
+import net.taler.wallet.kotlin.Base32Crockford
+
+internal class Planchet(private val crypto: Crypto) {
+
+ data class CreationRequest(
+ val value: Amount,
+ val feeWithdraw: Amount,
+ val denomPub: String,
+ val reservePub: String,
+ val reservePriv: String
+ )
+
+ data class CreationResult(
+ val coinPub: String,
+ val coinPriv: String,
+ val reservePub: String,
+ val denomPubHash: String,
+ val denomPub: String,
+ val blindingKey: String,
+ val withdrawSig: String,
+ val coinEv: String,
+ val coinValue: Amount,
+ val coinEvHash: String
+ )
+
+ internal fun create(req: CreationRequest, coinKeyPair: EddsaKeyPair, blindingFactor: ByteArray): CreationResult {
+ val reservePub = Base32Crockford.decode(req.reservePub)
+ val reservePriv = Base32Crockford.decode(req.reservePriv)
+ val denomPub = Base32Crockford.decode(req.denomPub)
+ val coinPubHash = crypto.sha512(coinKeyPair.publicKey)
+ val ev = crypto.rsaBlind(coinPubHash, blindingFactor, denomPub)
+ val amountWithFee = req.value + req.feeWithdraw
+ val denomPubHash = crypto.sha512(denomPub)
+ val evHash = crypto.sha512(ev)
+
+ val withdrawRequest = Signature.PurposeBuilder(Signature.RESERVE_WITHDRAW)
+ .put(reservePub)
+ .put(amountWithFee.toByteArray())
+ .put(req.feeWithdraw.toByteArray())
+ .put(denomPubHash)
+ .put(evHash)
+ .build()
+
+ val sig = crypto.eddsaSign(withdrawRequest, reservePriv)
+ return CreationResult(
+ blindingKey = Base32Crockford.encode(blindingFactor),
+ coinEv = Base32Crockford.encode(ev),
+ coinPriv = Base32Crockford.encode(coinKeyPair.privateKey),
+ coinPub = Base32Crockford.encode(coinKeyPair.publicKey),
+ coinValue = req.value,
+ denomPub = req.denomPub,
+ denomPubHash = Base32Crockford.encode(denomPubHash),
+ reservePub = req.reservePub,
+ withdrawSig = Base32Crockford.encode(sig),
+ coinEvHash = Base32Crockford.encode(evHash)
+ )
+ }
+
+ /**
+ * Create a pre-coin ([Planchet]) of the given [CreationRequest].
+ */
+ fun create(req: CreationRequest): CreationResult {
+ val coinKeyPair = crypto.createEddsaKeyPair()
+ val blindingFactor = crypto.getRandomBytes(32)
+ return create(req, coinKeyPair, blindingFactor)
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Recoup.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Recoup.kt
new file mode 100644
index 0000000..0f2b6df
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Recoup.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.wallet.kotlin.crypto
+
+import net.taler.wallet.kotlin.Base32Crockford
+import net.taler.wallet.kotlin.CoinRecord
+import net.taler.wallet.kotlin.CoinSourceType.REFRESH
+import net.taler.wallet.kotlin.crypto.Signature.Companion.WALLET_COIN_RECOUP
+
+internal class Recoup(private val crypto: Crypto) {
+
+ /**
+ * Request that we send to the exchange to get a payback.
+ */
+ data class Request(
+ /**
+ * Hashed denomination public key of the coin we want to get
+ * paid back.
+ */
+ val denomPubHash: String,
+
+ /**
+ * Signature over the coin public key by the denomination.
+ */
+ val denomSig: String,
+
+ /**
+ * Coin public key of the coin we want to refund.
+ */
+ val coinPub: String,
+
+ /**
+ * Blinding key that was used during withdraw,
+ * used to prove that we were actually withdrawing the coin.
+ */
+ val coinBlindKeySecret: String,
+
+ /**
+ * Signature made by the coin, authorizing the payback.
+ */
+ val coinSig: String,
+
+ /**
+ * Was the coin refreshed (and thus the recoup should go to the old coin)?
+ */
+ val refreshed: Boolean
+ )
+
+ /**
+ * Create and sign a message to recoup a coin.
+ */
+ fun createRequest(coin: CoinRecord): Request {
+ val p = Signature.PurposeBuilder(WALLET_COIN_RECOUP)
+ .put(Base32Crockford.decode(coin.coinPub))
+ .put(Base32Crockford.decode(coin.denomPubHash))
+ .put(Base32Crockford.decode(coin.blindingKey))
+ .build()
+ val coinSig = crypto.eddsaSign(p, Base32Crockford.decode(coin.coinPriv))
+ return Request(
+ coinBlindKeySecret = coin.blindingKey,
+ coinPub = coin.coinPub,
+ coinSig = Base32Crockford.encode(coinSig),
+ denomPubHash = coin.denomPubHash,
+ denomSig = coin.denomSig,
+ refreshed = coin.coinSource === REFRESH
+ )
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Refresh.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Refresh.kt
new file mode 100644
index 0000000..cd24b07
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Refresh.kt
@@ -0,0 +1,265 @@
+/*
+ * 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.wallet.kotlin.crypto
+
+import net.taler.wallet.kotlin.Amount
+import net.taler.wallet.kotlin.Base32Crockford
+import net.taler.wallet.kotlin.CoinRecord
+import net.taler.wallet.kotlin.Timestamp
+import net.taler.wallet.kotlin.crypto.Signature.Companion.WALLET_COIN_LINK
+import net.taler.wallet.kotlin.crypto.Signature.Companion.WALLET_COIN_MELT
+import net.taler.wallet.kotlin.crypto.Signature.PurposeBuilder
+import net.taler.wallet.kotlin.exchange.DenominationSelectionInfo
+import net.taler.wallet.kotlin.exchange.SelectedDenomination
+
+internal class Refresh(private val crypto: Crypto) {
+
+ data class RefreshSessionRecord(
+
+ /**
+ * Public key that's being melted in this session.
+ */
+ val meltCoinPub: String,
+
+ /**
+ * How much of the coin's value is melted away with this refresh session?
+ */
+ val amountRefreshInput: Amount,
+
+ /**
+ * Sum of the value of denominations we want to withdraw in this session, without fees.
+ */
+ val amountRefreshOutput: Amount,
+
+ /**
+ * Signature to confirm the melting.
+ */
+ val confirmSig: String,
+
+ /**
+ * Hashed denominations of the newly requested coins.
+ */
+ val newDenominationHashes: List<String>,
+
+ /**
+ * Denominations of the newly requested coins.
+ */
+ val newDenominations: List<String>,
+
+ /**
+ * Planchets for each cut-and-choose instance.
+ */
+ val planchetsForGammas: List<List<RefreshPlanchetRecord>>,
+
+ /**
+ * The transfer public keys, kappa of them.
+ */
+ val transferPublicKeys: List<String>,
+
+ /**
+ * Private keys for the transfer public keys.
+ */
+ val transferPrivateKeys: List<String>,
+
+ /**
+ * The no-reveal-index after we've done the melting.
+ */
+ val noRevealIndex: Int?,
+
+ /**
+ * Hash of the session.
+ */
+ val hash: String,
+
+ /**
+ * Timestamp when the refresh session finished.
+ */
+ val finishedTimestamp: Timestamp?,
+
+ /**
+ * When was this refresh session created?
+ */
+ val timestampCreated: Timestamp,
+
+ /**
+ * Base URL for the exchange we're doing the refresh with.
+ */
+ val exchangeBaseUrl: String
+ )
+
+ data class RefreshPlanchetRecord(
+ /**
+ * Public key for the coin.
+ */
+ val publicKey: String,
+ /**
+ * Private key for the coin.
+ */
+ val privateKey: String,
+ /**
+ * Blinded public key.
+ */
+ val coinEv: String,
+ /**
+ * Blinding key used.
+ */
+ val blindingKey: String
+ )
+
+ /**
+ * Create a new refresh session.
+ */
+ fun createRefreshSession(
+ exchangeBaseUrl: String,
+ meltCoin: CoinRecord,
+ meltFee: Amount,
+ newCoinDenominations: DenominationSelectionInfo,
+ kappa: Int = newCoinDenominations.selectedDenominations.size
+ ) : RefreshSessionRecord {
+ return createRefreshSession(exchangeBaseUrl, meltCoin, meltFee, newCoinDenominations, kappa) {
+ crypto.createEcdheKeyPair()
+ }
+ }
+
+ /**
+ * Create a new refresh session and allow to provide transfer key pairs for testing.
+ */
+ fun createRefreshSession(
+ exchangeBaseUrl: String,
+ meltCoin: CoinRecord,
+ meltFee: Amount,
+ newCoinDenominations: DenominationSelectionInfo,
+ kappa: Int = newCoinDenominations.selectedDenominations.size,
+ kappaKeys: (Int) -> EcdheKeyPair
+ ): RefreshSessionRecord {
+ val sessionHashState = crypto.getHashSha512State()
+
+ // create fresh transfer keys, one pair for each selected denomination (kappa-many)
+ val transferPublicKeys = ArrayList<String>()
+ val transferPrivateKeys = ArrayList<String>()
+ for (i in 0 until kappa) {
+ val transferKeyPair = kappaKeys(i)
+ sessionHashState.update(transferKeyPair.publicKey)
+ transferPrivateKeys.add(Base32Crockford.encode(transferKeyPair.privateKey))
+ transferPublicKeys.add(Base32Crockford.encode(transferKeyPair.publicKey))
+ }
+
+ // add denomination public keys to session hash
+ val newDenominations = ArrayList<String>()
+ val newDenominationHashes = ArrayList<String>()
+ for (selectedDenomination in newCoinDenominations.selectedDenominations) {
+ for (i in 0 until selectedDenomination.count) {
+ newDenominations.add(selectedDenomination.denominationRecord.denomPub)
+ newDenominationHashes.add(selectedDenomination.denominationRecord.denomPubHash)
+ sessionHashState.update(Base32Crockford.decode(selectedDenomination.denominationRecord.denomPub))
+ }
+ }
+
+ // add public key of melted coin to session hash
+ sessionHashState.update(Base32Crockford.decode(meltCoin.coinPub))
+
+ // calculate total value with all fees and add to session hash
+ val (totalOutput, withdrawFee) = calculateOutputAndWithdrawFee(newCoinDenominations.selectedDenominations)
+ val valueWithFee = totalOutput + withdrawFee + meltFee
+ sessionHashState.update(valueWithFee.toByteArray())
+
+ val planchetsForGammas = ArrayList<ArrayList<RefreshPlanchetRecord>>()
+ for (i in 0 until kappa) {
+ val planchets = ArrayList<RefreshPlanchetRecord>()
+ for (selectedDenomination in newCoinDenominations.selectedDenominations) {
+ for (k in 0 until selectedDenomination.count) {
+ val coinNumber = planchets.size
+ val transferPrivateKey = Base32Crockford.decode(transferPrivateKeys[i])
+ val oldCoinPub = Base32Crockford.decode(meltCoin.coinPub)
+ val transferSecret = crypto.keyExchangeEcdheEddsa(transferPrivateKey, oldCoinPub)
+ val fresh = crypto.setupRefreshPlanchet(transferSecret, coinNumber)
+ val publicKeyHash = crypto.sha512(fresh.coinPublicKey)
+ val denominationPub = Base32Crockford.decode(selectedDenomination.denominationRecord.denomPub)
+ val ev = crypto.rsaBlind(publicKeyHash, fresh.bks, denominationPub)
+ val planchet = RefreshPlanchetRecord(
+ blindingKey = Base32Crockford.encode(fresh.bks),
+ coinEv = Base32Crockford.encode(ev),
+ privateKey = Base32Crockford.encode(fresh.coinPrivateKey),
+ publicKey = Base32Crockford.encode(fresh.coinPublicKey)
+ )
+ planchets.add(planchet)
+ sessionHashState.update(ev)
+ }
+ }
+ planchetsForGammas.add(planchets)
+ }
+
+ val sessionHash = sessionHashState.final()
+
+ // make a signature over sessionHash, value (again?), meltFee and meltCoin public key with meltCoin private key
+ val confirmData = PurposeBuilder(WALLET_COIN_MELT)
+ .put(sessionHash)
+ .put(valueWithFee.toByteArray())
+ .put(meltFee.toByteArray())
+ .put(Base32Crockford.decode(meltCoin.coinPub))
+ .build()
+ val confirmSignature = crypto.eddsaSign(confirmData, Base32Crockford.decode(meltCoin.coinPriv))
+
+ return RefreshSessionRecord(
+ confirmSig = Base32Crockford.encode(confirmSignature),
+ exchangeBaseUrl = exchangeBaseUrl,
+ hash = Base32Crockford.encode(sessionHash),
+ meltCoinPub = meltCoin.coinPub,
+ newDenominationHashes = newDenominationHashes,
+ newDenominations = newDenominations,
+ noRevealIndex = null,
+ planchetsForGammas = planchetsForGammas,
+ transferPrivateKeys = transferPrivateKeys,
+ transferPublicKeys = transferPublicKeys,
+ amountRefreshOutput = totalOutput,
+ amountRefreshInput = valueWithFee,
+ timestampCreated = Timestamp.now(),
+ finishedTimestamp = null
+ )
+ }
+
+ private fun calculateOutputAndWithdrawFee(selectedDenomination: List<SelectedDenomination>): Pair<Amount, Amount> {
+ val currency = selectedDenomination[0].denominationRecord.value.currency
+ var total = Amount.zero(currency)
+ var fee = Amount.zero(currency)
+ for (ncd in selectedDenomination) {
+ total += ncd.denominationRecord.value * ncd.count
+ fee += ncd.denominationRecord.feeWithdraw * ncd.count
+ }
+ return Pair(total, fee)
+ }
+
+ fun signCoinLink(
+ oldCoinPrivateKey: String,
+ newDenominationHash: String,
+ oldCoinPublicKey: String,
+ transferPublicKey: String,
+ coinEv: String
+ ): String {
+ val coinEvHash = crypto.sha512(Base32Crockford.decode(coinEv))
+ val coinLink = PurposeBuilder(WALLET_COIN_LINK)
+ .put(Base32Crockford.decode(newDenominationHash))
+ .put(Base32Crockford.decode(oldCoinPublicKey))
+ .put(Base32Crockford.decode(transferPublicKey))
+ .put(coinEvHash)
+ .build()
+ val coinPrivateKey = Base32Crockford.decode(oldCoinPrivateKey)
+ val sig = crypto.eddsaSign(coinLink, coinPrivateKey)
+ return Base32Crockford.encode(sig)
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Signature.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Signature.kt
new file mode 100644
index 0000000..9b06756
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Signature.kt
@@ -0,0 +1,155 @@
+/*
+ * 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.wallet.kotlin.crypto
+
+import net.taler.wallet.kotlin.Base32Crockford
+import net.taler.wallet.kotlin.crypto.CryptoImpl.Companion.toByteArray
+import net.taler.wallet.kotlin.exchange.DenominationRecord
+import net.taler.wallet.kotlin.exchange.WireFee
+
+internal class Signature(private val crypto: Crypto) {
+
+ @Suppress("unused")
+ companion object {
+ const val RESERVE_WITHDRAW = 1200
+ const val WALLET_COIN_DEPOSIT = 1201
+ const val MASTER_DENOMINATION_KEY_VALIDITY = 1025
+ const val MASTER_WIRE_FEES = 1028
+ const val MASTER_WIRE_DETAILS = 1030
+ const val WALLET_COIN_MELT = 1202
+ const val TEST = 4242
+ const val MERCHANT_PAYMENT_OK = 1104
+ const val WALLET_COIN_RECOUP = 1203
+ const val WALLET_COIN_LINK = 1204
+ const val EXCHANGE_CONFIRM_RECOUP = 1039
+ const val EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041
+ }
+
+ internal class PurposeBuilder(private val purposeNum: Int) {
+ private val chunks = ArrayList<ByteArray>()
+
+ fun put(bytes: ByteArray): PurposeBuilder {
+ chunks.add(bytes)
+ return this
+ }
+
+ fun build(): ByteArray {
+ var payloadLen = 0
+ for (c in chunks) payloadLen += c.size
+ val size = 4 + 4 + payloadLen
+ val bytes = ByteArray(size)
+ size.toByteArray().copyInto(bytes, 0)
+ purposeNum.toByteArray().copyInto(bytes, 4)
+ var offset = 8
+ for (c in chunks) {
+ c.copyInto(bytes, offset)
+ offset += c.size
+ }
+ return bytes
+ }
+ }
+
+ private fun verifyPayment(sig: ByteArray, contractHash: ByteArray, merchantPub: ByteArray): Boolean {
+ val p = PurposeBuilder(MERCHANT_PAYMENT_OK)
+ .put(contractHash)
+ .build()
+ return crypto.eddsaVerify(p, sig, merchantPub)
+ }
+
+ /**
+ * Verifies an EdDSA payment signature made with [MERCHANT_PAYMENT_OK].
+ *
+ * @param merchantPub an EdDSA public key, usually belonging to a merchant.
+ *
+ * @return true if the signature is valid, false otherwise
+ */
+ fun verifyPayment(sig: String, contractHash: String, merchantPub: String): Boolean {
+ val sigBytes = Base32Crockford.decode(sig)
+ val hashBytes = Base32Crockford.decode(contractHash)
+ val pubBytes = Base32Crockford.decode(merchantPub)
+ return verifyPayment(sigBytes, hashBytes, pubBytes)
+ }
+
+ /**
+ * Verifies an EdDSA wire fee signature made with [MASTER_WIRE_FEES].
+ *
+ * @param masterPub an EdDSA public key
+ *
+ * @return true if the signature is valid, false otherwise
+ */
+ fun verifyWireFee(type: String, wireFee: WireFee, masterPub: String): Boolean {
+ val p = PurposeBuilder(MASTER_WIRE_FEES)
+ .put(crypto.sha512("$type\u0000".encodeToByteArray()))
+ .put(wireFee.startStamp.roundedToByteArray())
+ .put(wireFee.endStamp.roundedToByteArray())
+ .put(wireFee.wireFee.toByteArray())
+ .put(wireFee.closingFee.toByteArray())
+ .build()
+ val sig = Base32Crockford.decode(wireFee.signature)
+ val pub = Base32Crockford.decode(masterPub)
+ return crypto.eddsaVerify(p, sig, pub)
+ }
+
+ /**
+ * Verifies an EdDSA denomination record signature made with [MASTER_DENOMINATION_KEY_VALIDITY].
+ *
+ * @param masterPub an EdDSA public key
+ *
+ * @return true if the signature is valid, false otherwise
+ */
+ fun verifyDenominationRecord(d: DenominationRecord, masterPub: String): Boolean {
+ val pub = Base32Crockford.decode(masterPub)
+ val p = PurposeBuilder(MASTER_DENOMINATION_KEY_VALIDITY)
+ .put(pub)
+ .put(d.stampStart.roundedToByteArray())
+ .put(d.stampExpireWithdraw.roundedToByteArray())
+ .put(d.stampExpireDeposit.roundedToByteArray())
+ .put(d.stampExpireLegal.roundedToByteArray())
+ .put(d.value.toByteArray())
+ .put(d.feeWithdraw.toByteArray())
+ .put(d.feeDeposit.toByteArray())
+ .put(d.feeRefresh.toByteArray())
+ .put(d.feeRefund.toByteArray())
+ .put(Base32Crockford.decode(d.denomPubHash))
+ .build()
+ val sig = Base32Crockford.decode(d.masterSig)
+ return crypto.eddsaVerify(p, sig, pub)
+ }
+
+ /**
+ * Verifies an EdDSA wire account signature made with [MASTER_WIRE_DETAILS].
+ *
+ * @param masterPub an EdDSA public key
+ *
+ * @return true if the signature is valid, false otherwise
+ */
+ fun verifyWireAccount(paytoUri: String, signature: String, masterPub: String): Boolean {
+ val h = crypto.kdf(
+ 64,
+ "exchange-wire-signature".encodeToByteArray(),
+ "$paytoUri\u0000".encodeToByteArray(),
+ ByteArray(0)
+ )
+ val p = PurposeBuilder(MASTER_WIRE_DETAILS)
+ .put(h)
+ .build()
+ val sig = Base32Crockford.decode(signature)
+ val pub = Base32Crockford.decode(masterPub)
+ return crypto.eddsaVerify(p, sig, pub)
+ }
+
+}
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Auditor.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Auditor.kt
new file mode 100644
index 0000000..4df0bdf
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/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.wallet.kotlin.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/wallet/kotlin/exchange/Denomination.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Denomination.kt
new file mode 100644
index 0000000..88a81fd
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/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.wallet.kotlin.exchange
+
+import kotlinx.serialization.Serializable
+import net.taler.wallet.kotlin.Amount
+import net.taler.wallet.kotlin.Base32Crockford
+import net.taler.wallet.kotlin.Duration
+import net.taler.wallet.kotlin.Timestamp
+import net.taler.wallet.kotlin.exchange.DenominationStatus.Unverified
+import net.taler.wallet.kotlin.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/wallet/kotlin/exchange/Exchange.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Exchange.kt
new file mode 100644
index 0000000..7a6ac7f
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/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.wallet.kotlin.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.wallet.kotlin.Amount
+import net.taler.wallet.kotlin.Base32Crockford
+import net.taler.wallet.kotlin.Db
+import net.taler.wallet.kotlin.DbFactory
+import net.taler.wallet.kotlin.Timestamp
+import net.taler.wallet.kotlin.compareVersions
+import net.taler.wallet.kotlin.crypto.Crypto
+import net.taler.wallet.kotlin.crypto.CryptoFactory
+import net.taler.wallet.kotlin.crypto.Signature
+import net.taler.wallet.kotlin.exchange.DenominationStatus.Unverified
+import net.taler.wallet.kotlin.exchange.ExchangeUpdateReason.Initial
+import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.FetchKeys
+import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.FetchTerms
+import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.FetchWire
+import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.FinalizeUpdate
+import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.Finished
+import net.taler.wallet.kotlin.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 const val PROTOCOL_VERSION = "8:0:0"
+ fun getVersionMatch(version: String) = compareVersions(PROTOCOL_VERSION, 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/wallet/kotlin/exchange/ExchangeRecord.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/ExchangeRecord.kt
new file mode 100644
index 0000000..9bfd649
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/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.wallet.kotlin.exchange
+
+import net.taler.wallet.kotlin.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/wallet/kotlin/exchange/Keys.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Keys.kt
new file mode 100644
index 0000000..54806f9
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/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.wallet.kotlin.exchange
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import kotlinx.serialization.Serializable
+import net.taler.wallet.kotlin.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/wallet/kotlin/exchange/Wire.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Wire.kt
new file mode 100644
index 0000000..c8fae88
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/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.wallet.kotlin.exchange
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.taler.wallet.kotlin.Amount
+import net.taler.wallet.kotlin.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
+)
diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/operations/Withdraw.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/operations/Withdraw.kt
new file mode 100644
index 0000000..e51e9ec
--- /dev/null
+++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/operations/Withdraw.kt
@@ -0,0 +1,305 @@
+/*
+ * 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.wallet.kotlin.operations
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.taler.wallet.kotlin.Amount
+import net.taler.wallet.kotlin.Db
+import net.taler.wallet.kotlin.DbFactory
+import net.taler.wallet.kotlin.TalerUri.parseWithdrawUri
+import net.taler.wallet.kotlin.Timestamp
+import net.taler.wallet.kotlin.VersionMatchResult
+import net.taler.wallet.kotlin.crypto.Crypto
+import net.taler.wallet.kotlin.crypto.CryptoFactory
+import net.taler.wallet.kotlin.crypto.Signature
+import net.taler.wallet.kotlin.exchange.DenominationRecord
+import net.taler.wallet.kotlin.exchange.DenominationSelectionInfo
+import net.taler.wallet.kotlin.exchange.DenominationStatus.Unverified
+import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedBad
+import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedGood
+import net.taler.wallet.kotlin.exchange.Exchange
+import net.taler.wallet.kotlin.exchange.ExchangeListItem
+import net.taler.wallet.kotlin.exchange.ExchangeRecord
+import net.taler.wallet.kotlin.exchange.ExchangeWireInfo
+import net.taler.wallet.kotlin.exchange.SelectedDenomination
+import net.taler.wallet.kotlin.getDefaultHttpClient
+
+internal class Withdraw(
+ private val httpClient: HttpClient = getDefaultHttpClient(),
+ private val db: Db = DbFactory().openDb(),
+ private val crypto: Crypto = CryptoFactory.getCrypto(),
+ private val signature: Signature = Signature(crypto),
+ private val exchange: Exchange = Exchange(crypto, signature, httpClient, db = db)
+) {
+
+ data class BankDetails(
+ val amount: Amount,
+ val selectionDone: Boolean,
+ val transferDone: Boolean,
+ val senderPaytoUri: String?,
+ val suggestedExchange: String?,
+ val confirmTransferUrl: String?,
+ val wireTypes: List<String>,
+ val extractedStatusUrl: String
+ )
+
+ @Serializable
+ data class Response(
+ @SerialName("selection_done")
+ val selectionDone: Boolean,
+ @SerialName("transfer_done")
+ val transferDone: Boolean,
+ val amount: Amount,
+ @SerialName("wire_types")
+ val wireTypes: List<String>,
+ @SerialName("sender_wire")
+ val senderPaytoUri: String?,
+ @SerialName("suggested_exchange")
+ val suggestedExchange: String?,
+ @SerialName("confirm_transfer_url")
+ val confirmTransferUrl: String?
+ ) {
+ fun toBankDetails(extractedStatusUrl: String) = BankDetails(
+ amount = amount,
+ confirmTransferUrl = confirmTransferUrl,
+ extractedStatusUrl = extractedStatusUrl,
+ selectionDone = selectionDone,
+ senderPaytoUri = senderPaytoUri,
+ suggestedExchange = suggestedExchange,
+ transferDone = transferDone,
+ wireTypes = wireTypes
+ )
+ }
+
+ suspend fun getBankInfo(talerWithdrawUri: String): BankDetails {
+ val uriResult =
+ parseWithdrawUri(talerWithdrawUri) ?: throw Error("Can't parse URI $talerWithdrawUri")
+ val url =
+ "${uriResult.bankIntegrationApiBaseUrl}api/withdraw-operation/${uriResult.withdrawalOperationId}"
+ val response: Response = httpClient.get(url)
+ return response.toBankDetails(url)
+ }
+
+ /**
+ * Information about what will happen when creating a reserve.
+ *
+ * Sent to the wallet frontend to be rendered and shown to the user.
+ */
+ data class WithdrawalDetails(
+ /**
+ * Exchange that the reserve will be created at.
+ */
+ // TODO we probably don't need to include our internal exchange record in here
+ val exchange: ExchangeRecord,
+
+ /**
+ * Selected denominations for withdraw.
+ */
+ val selectedDenominations: DenominationSelectionInfo,
+
+ /**
+ * Fees for withdraw.
+ */
+ val withdrawFee: Amount,
+
+ /**
+ * Remaining balance that is too small to be withdrawn.
+ */
+ val overhead: Amount,
+
+ /**
+ * The earliest deposit expiration of the selected coins.
+ */
+ // TODO what is this needed for?
+ val earliestDepositExpiration: Timestamp,
+
+ /**
+ * Number of currently offered denominations.
+ */
+ // TODO what is this needed for?
+ val numOfferedDenoms: Int
+ ) {
+ init {
+ check(exchange.details != null)
+ check(exchange.wireInfo != null)
+ }
+
+ /**
+ * Filtered wire info to send to the bank.
+ */
+ val exchangeWireAccounts: List<String> get() = exchange.wireInfo!!.accounts.map { it.paytoUri }
+
+ /**
+ * Wire fees from the exchange.
+ */
+ val wireFees: ExchangeWireInfo get() = exchange.wireInfo!!
+
+ /**
+ * Did the user already accept the current terms of service for the exchange?
+ */
+ val termsOfServiceAccepted: Boolean get() = exchange.termsOfServiceAccepted
+
+ /**
+ * Result of checking the wallet's version against the exchange's version.
+ */
+ val versionMatch: VersionMatchResult?
+ get() = Exchange.getVersionMatch(exchange.details!!.protocolVersion)
+
+ }
+
+ internal suspend fun getWithdrawalDetails(
+ exchangeBaseUrl: String,
+ amount: Amount
+ ): WithdrawalDetails {
+ val exchange = exchange.updateFromUrl(exchangeBaseUrl)
+ check(exchange.details != null)
+ check(exchange.wireInfo != null)
+ val selectedDenominations = selectDenominations(exchange, amount)
+ val possibleDenominations =
+ db.getDenominationsByBaseUrl(exchangeBaseUrl).filter { it.isOffered }
+ // TODO determine trust and audit status
+ return WithdrawalDetails(
+ exchange = exchange,
+ selectedDenominations = selectedDenominations,
+ withdrawFee = selectedDenominations.totalWithdrawCost - selectedDenominations.totalCoinValue,
+ overhead = amount - selectedDenominations.totalWithdrawCost,
+ earliestDepositExpiration = selectedDenominations.getEarliestDepositExpiry(),
+ numOfferedDenoms = possibleDenominations.size
+ )
+ }
+
+ /**
+ * Get a list of denominations to withdraw from the given exchange for the given amount,
+ * making sure that all denominations' signatures are verified.
+ */
+ internal suspend fun selectDenominations(
+ exchange: ExchangeRecord,
+ amount: Amount
+ ): DenominationSelectionInfo {
+ val exchangeDetails =
+ exchange.details ?: throw Error("Exchange $exchange details not available.")
+
+ val possibleDenominations = getPossibleDenominations(exchange.baseUrl)
+ val selectedDenominations = getDenominationSelection(amount, possibleDenominations)
+ // TODO consider validating denominations before writing them into the DB
+ for (selectedDenomination in selectedDenominations.selectedDenominations) {
+ var denomination = selectedDenomination.denominationRecord
+ if (denomination.status == Unverified) {
+ val valid = signature.verifyDenominationRecord(
+ denomination,
+ exchangeDetails.masterPublicKey
+ )
+ denomination = if (!valid) {
+ denomination.copy(status = VerifiedBad)
+ } else {
+ denomination.copy(status = VerifiedGood)
+ }
+ db.put(denomination)
+ }
+ if (denomination.status == VerifiedBad) throw Error("Exchange $exchange has bad denomination.")
+ }
+ return selectedDenominations
+ }
+
+ suspend fun getPossibleDenominations(exchangeBaseUrl: String): List<DenominationRecord> {
+ return db.getDenominationsByBaseUrl(exchangeBaseUrl).filter { denomination ->
+ (denomination.status == Unverified || denomination.status == VerifiedGood) &&
+ !denomination.isRevoked
+ }
+ }
+
+ /**
+ * Get a list of denominations (with repetitions possible)
+ * whose total value is as close as possible to the available amount, but never larger.
+ *
+ * Note that this algorithm does not try to optimize withdrawal fees.
+ */
+ fun getDenominationSelection(
+ amount: Amount,
+ denoms: List<DenominationRecord>
+ ): DenominationSelectionInfo {
+ val selectedDenominations = ArrayList<SelectedDenomination>()
+ var totalCoinValue = Amount.zero(amount.currency)
+ var totalWithdrawCost = Amount.zero(amount.currency)
+
+ // denominations need to be sorted, so we try the highest ones first
+ val now = Timestamp.now()
+ val denominations = denoms.filter { it.isWithdrawable(now) }.sortedByDescending { it.value }
+ var remainingAmount = amount.copy()
+ val zero = Amount.zero(amount.currency)
+ for (d in denominations) {
+ var count = 0
+ val totalCost = d.value + d.feeWithdraw
+ // keep adding this denomination as long as its total cost fits into remaining amount
+ while (remainingAmount >= totalCost) {
+ remainingAmount -= totalCost
+ count++
+ }
+ // calculate new totals based on count-many added denominations
+ if (count > 0) {
+ totalCoinValue += d.value * count
+ totalWithdrawCost += totalCost * count
+ selectedDenominations.add(SelectedDenomination(count, d))
+ }
+ // stop early if nothing is remaining
+ if (remainingAmount == zero) break
+ }
+ return DenominationSelectionInfo(
+ selectedDenominations = selectedDenominations,
+ totalCoinValue = totalCoinValue,
+ totalWithdrawCost = totalWithdrawCost
+ )
+ }
+
+}
+
+data class WithdrawalDetailsForUri(
+ /**
+ * The amount that the user wants to withdraw
+ */
+ val amount: Amount,
+
+ /**
+ * Exchange suggested by the wallet
+ */
+ val defaultExchangeBaseUrl: String?,
+
+ /**
+ * A list of exchanges that can be used for this withdrawal
+ */
+ val possibleExchanges: List<ExchangeListItem>
+)
+
+data class WithdrawalDetails(
+ /**
+ * Did the user accept the current version of the exchange's terms of service?
+ */
+ val tosAccepted: Boolean,
+
+ /**
+ * Amount that will be transferred to the exchange.
+ */
+ val amountRaw: Amount,
+
+ /**
+ * Amount that will be added to the user's wallet balance.
+ */
+ val amountEffective: Amount
+)