From 7f721d00f1e10fbe3ea01fcbec1a3be558f17860 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 24 Jun 2020 13:59:00 -0300 Subject: Add Amount class with tests --- .../kotlin/net/taler/wallet/kotlin/Amount.kt | 194 +++++++++++++++ .../net/taler/wallet/kotlin/crypto/CryptoImpl.kt | 30 ++- .../kotlin/net/taler/wallet/kotlin/AmountTest.kt | 270 +++++++++++++++++++++ 3 files changed, 485 insertions(+), 9 deletions(-) create mode 100644 src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt create mode 100644 src/commonTest/kotlin/net/taler/wallet/kotlin/AmountTest.kt diff --git a/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt b/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt new file mode 100644 index 0000000..a078089 --- /dev/null +++ b/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt @@ -0,0 +1,194 @@ +/* + * 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 + */ + +package net.taler.wallet.kotlin + +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) + +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 { + + companion object { + + 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 + } + + } + + 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 { + 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/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt b/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt index 98ee656..e6995b1 100644 --- a/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt +++ b/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt @@ -1,7 +1,28 @@ 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) }) } @@ -15,13 +36,4 @@ abstract class CryptoImpl : Crypto { return FreshCoin(eddsaGetPublic(coinPrivateKey), coinPrivateKey, bks) } - private 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 - } - } diff --git a/src/commonTest/kotlin/net/taler/wallet/kotlin/AmountTest.kt b/src/commonTest/kotlin/net/taler/wallet/kotlin/AmountTest.kt new file mode 100644 index 0000000..578874d --- /dev/null +++ b/src/commonTest/kotlin/net/taler/wallet/kotlin/AmountTest.kt @@ -0,0 +1,270 @@ +/* + * 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 + */ + +package net.taler.wallet.kotlin + +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +class AmountTest { + + companion object { + private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + fun getRandomString(minLength: Int = 1, maxLength: Int = Random.nextInt(0, 1337)) = (minLength..maxLength) + .map { Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString("") + + fun getRandomAmount() = getRandomAmount(getRandomString(1, Random.nextInt(1, 12))) + + fun getRandomAmount(currency: String): Amount { + val value = Random.nextLong(0, Amount.MAX_VALUE) + val fraction = Random.nextInt(0, Amount.MAX_FRACTION) + return Amount(currency, value, fraction) + } + } + + @Test + fun testFromJSONString() { + var str = "TESTKUDOS:23.42" + var amount = Amount.fromJSONString(str) + assertEquals(str, amount.toJSONString()) + assertEquals("TESTKUDOS", amount.currency) + assertEquals(23, amount.value) + assertEquals((0.42 * 1e8).toInt(), amount.fraction) + assertEquals("23.42 TESTKUDOS", amount.toString()) + + str = "EUR:500000000.00000001" + amount = Amount.fromJSONString(str) + assertEquals(str, amount.toJSONString()) + assertEquals("EUR", amount.currency) + assertEquals(500000000, amount.value) + assertEquals(1, amount.fraction) + assertEquals("500000000.00000001 EUR", amount.toString()) + + str = "EUR:1500000000.00000003" + amount = Amount.fromJSONString(str) + assertEquals(str, amount.toJSONString()) + assertEquals("EUR", amount.currency) + assertEquals(1500000000, amount.value) + assertEquals(3, amount.fraction) + assertEquals("1500000000.00000003 EUR", amount.toString()) + } + + @Test + fun testFromJSONStringAcceptsMaxValuesRejectsAbove() { + val maxValue = 4503599627370496 + val str = "TESTKUDOS123:$maxValue.99999999" + val amount = Amount.fromJSONString(str) + assertEquals(str, amount.toJSONString()) + assertEquals("TESTKUDOS123", amount.currency) + assertEquals(maxValue, amount.value) + assertEquals("$maxValue.99999999 TESTKUDOS123", amount.toString()) + + // longer currency not accepted + assertThrows("longer currency was accepted") { + Amount.fromJSONString("TESTKUDOS1234:$maxValue.99999999") + } + + // max value + 1 not accepted + assertThrows("max value + 1 was accepted") { + Amount.fromJSONString("TESTKUDOS123:${maxValue + 1}.99999999") + } + + // max fraction + 1 not accepted + assertThrows("max fraction + 1 was accepted") { + Amount.fromJSONString("TESTKUDOS123:$maxValue.999999990") + } + } + + @Test + fun testFromJSONStringRejections() { + assertThrows { + Amount.fromJSONString("TESTKUDOS:0,5") + } + assertThrows { + Amount.fromJSONString("+TESTKUDOS:0.5") + } + assertThrows { + Amount.fromJSONString("0.5") + } + assertThrows { + Amount.fromJSONString(":0.5") + } + assertThrows { + Amount.fromJSONString("EUR::0.5") + } + assertThrows { + Amount.fromJSONString("EUR:.5") + } + } + + @Test + fun testAddition() { + assertEquals( + Amount.fromJSONString("EUR:2"), + Amount.fromJSONString("EUR:1") + Amount.fromJSONString("EUR:1") + ) + assertEquals( + Amount.fromJSONString("EUR:3"), + Amount.fromJSONString("EUR:1.5") + Amount.fromJSONString("EUR:1.5") + ) + assertEquals( + Amount.fromJSONString("EUR:500000000.00000002"), + Amount.fromJSONString("EUR:500000000.00000001") + Amount.fromJSONString("EUR:0.00000001") + ) + assertThrows("addition didn't overflow") { + Amount.fromJSONString("EUR:4503599627370496.99999999") + Amount.fromJSONString("EUR:0.00000001") + } + assertThrows("addition didn't overflow") { + Amount.fromJSONString("EUR:4000000000000000") + Amount.fromJSONString("EUR:4000000000000000") + } + } + + @Test + fun testTimes() { + assertEquals( + Amount.fromJSONString("EUR:2"), + Amount.fromJSONString("EUR:2") * 1 + ) + assertEquals( + Amount.fromJSONString("EUR:2"), + Amount.fromJSONString("EUR:1") * 2 + ) + assertEquals( + Amount.fromJSONString("EUR:4.5"), + Amount.fromJSONString("EUR:1.5") * 3 + ) + assertEquals( + Amount.fromJSONString("EUR:1500000000.00000003"), + Amount.fromJSONString("EUR:500000000.00000001") * 3 + ) + assertThrows("times didn't overflow") { + Amount.fromJSONString("EUR:4000000000000000") * 2 + } + } + + @Test + fun testSubtraction() { + assertEquals( + Amount.fromJSONString("EUR:0"), + Amount.fromJSONString("EUR:1") - Amount.fromJSONString("EUR:1") + ) + assertEquals( + Amount.fromJSONString("EUR:1.5"), + Amount.fromJSONString("EUR:3") - Amount.fromJSONString("EUR:1.5") + ) + assertEquals( + Amount.fromJSONString("EUR:500000000.00000001"), + Amount.fromJSONString("EUR:500000000.00000002") - Amount.fromJSONString("EUR:0.00000001") + ) + assertThrows("subtraction didn't underflow") { + Amount.fromJSONString("EUR:23.42") - Amount.fromJSONString("EUR:42.23") + } + assertThrows("subtraction didn't underflow") { + Amount.fromJSONString("EUR:0.5") - Amount.fromJSONString("EUR:0.50000001") + } + } + + @Test + fun testIsZero() { + assertTrue(Amount.zero("EUR").isZero()) + assertTrue(Amount.fromJSONString("EUR:0").isZero()) + assertTrue(Amount.fromJSONString("EUR:0.0").isZero()) + assertTrue(Amount.fromJSONString("EUR:0.00000").isZero()) + assertTrue((Amount.fromJSONString("EUR:1.001") - Amount.fromJSONString("EUR:1.001")).isZero()) + + assertFalse(Amount.fromJSONString("EUR:0.00000001").isZero()) + assertFalse(Amount.fromJSONString("EUR:1.0").isZero()) + assertFalse(Amount.fromJSONString("EUR:0001.0").isZero()) + } + + @Test + fun testComparision() { + assertTrue(Amount.fromJSONString("EUR:0") <= Amount.fromJSONString("EUR:0")) + assertTrue(Amount.fromJSONString("EUR:0") <= Amount.fromJSONString("EUR:0.00000001")) + assertTrue(Amount.fromJSONString("EUR:0") < Amount.fromJSONString("EUR:0.00000001")) + assertTrue(Amount.fromJSONString("EUR:0") < Amount.fromJSONString("EUR:1")) + assertEquals(Amount.fromJSONString("EUR:0"), Amount.fromJSONString("EUR:0")) + assertEquals(Amount.fromJSONString("EUR:42"), Amount.fromJSONString("EUR:42")) + assertEquals(Amount.fromJSONString("EUR:42.00000001"), Amount.fromJSONString("EUR:42.00000001")) + assertTrue(Amount.fromJSONString("EUR:42.00000001") >= Amount.fromJSONString("EUR:42.00000001")) + assertTrue(Amount.fromJSONString("EUR:42.00000002") >= Amount.fromJSONString("EUR:42.00000001")) + assertTrue(Amount.fromJSONString("EUR:42.00000002") > Amount.fromJSONString("EUR:42.00000001")) + assertTrue(Amount.fromJSONString("EUR:0.00000002") > Amount.fromJSONString("EUR:0.00000001")) + assertTrue(Amount.fromJSONString("EUR:0.00000001") > Amount.fromJSONString("EUR:0")) + assertTrue(Amount.fromJSONString("EUR:2") > Amount.fromJSONString("EUR:1")) + + assertThrows("could compare amounts with different currencies") { + Amount.fromJSONString("EUR:0.5") < Amount.fromJSONString("USD:0.50000001") + } + } + + @Test + fun testToByteArray() { + val vectors = listOf( + Pair("ceicWVf9GhJ:3902026702525079.40496378", "006XSQV3G899E0K9XKX66SB9CDBNCSHS8XM4M00"), + Pair("asYDLuK2A:3800267550024600.02072907", "006R0MNXBVHSG00ZM55P2WTS8H67AJSJ8400000"), + Pair("pV1m:1347558259914570.09786232", "002CK66VCNVMM04NADW70NHHDM0000000000000"), + Pair("geO82l:553744321840253.41004983", "000ZF855K627T0KHNYVPESAF70S6R0000000000"), + Pair("B9bWK7WPEO:3663912678613976.12122563", "006G8KS5P9HXG05RZ71M4EB2AX5KENTG8N7G000"), + Pair("X:1537372109907438.77850768", "002QCETPFYJYW153X285G000000000000000000"), + Pair("5:4271492725553118.39728399", "007JSSK6J4VXW0JY6M7KA000000000000000000"), + Pair("OSdV:801656289790342.08256189", "001DJ6H6CA4RC03XZAYMYMV4AR0000000000000"), + Pair("Y6:2908617536334646.94126271", "0055AQTB19NKC1CW82ZNJDG0000000000000000"), + Pair("kSHoOZj:2610656582865206.00292046", "004MCR6T828KC004EK76PMT8DX7NMTG00000000"), + Pair("GkhLXrlGES:4246330707533398.83874252", "007HC0Z9DFF5C17ZT764ETV89HC74V278N9G000"), + Pair("CNS09:738124490298524.71259462", "0019YMG01DA9R11ZAN346KJK60WG00000000000"), + Pair("sw0b1tKXZym:2132978464977419.28199478", "003S7VNZPZS0P0DE98V76XSGC8RQ8JTRB9WPT00"), + Pair("fC:1275322307696988.17178522", "0028FSGX3ZCNR0863YD6CGR0000000000000000"), + Pair("cRai6j:166032749022734.69444771", "0009E0C30V70W113MJHP6MK1D4V6M0000000000"), + Pair("KOADwTb3:3932974019564218.48282023", "006ZJ16ZB39BM0Q0Q6KMPKT18HVN8RHK0000000"), + Pair("9Fi9wcLgDe:1268366772151214.97268853", "002834N6WRHTW1EC6HTKJHK975VP6K378HJG000"), + Pair("SDN:3370670470236379.88943272", "005ZK6V0124DP1AD5AM56H2E000000000000000"), + Pair("zGCP5V:4010014441349620.76121145", "0073Y5HYA8GZ8149GGWQMHT3A0TNC0000000000"), + Pair("VsW1JjBLn:2037070181191907.99717275", "003KSD2WH18E61FHJ2DNCWTQ6556MGJCDR00000"), + Pair("A:1806895799429502.00887758", "0036PQ5P8NMQW00DHF742000000000000000000"), + Pair("njA8:4015261148004966.43708687", "00747PYPD116C0MTY47PWTJ1700000000000000"), + Pair("Bwq:3562876074139250.28829179", "006AGTNTRWF740DQWQXM4XVH000000000000000"), + Pair("8e75v8:3716241006992995.95213823", "006K7SP93WF661DCV3ZKGS9Q6NV3G0000000000"), + Pair("XrnbQTTn:3887603772953949.94721267", "006WZGA9X8ANT1D5AKSNGWKEC98N8N3E0000000"), + Pair("MIN:0.00000001", "0000000000000000000MTJAE000000000000000"), + Pair("MAX:4503599627370496.99999999", "00800000000001FNW3ZMTGAR000000000000000") + ) + for (v in vectors) { + val amount = Amount.fromJSONString(v.first) + val encodedBytes = Base32Crockford.encode(amount.toByteArray()) + assertEquals(v.second, encodedBytes) + } + } + + private inline fun assertThrows( + msg: String? = null, + function: () -> Any + ) { + try { + function.invoke() + fail(msg) + } catch (e: Exception) { + assertTrue(e is T) + } + } + +} -- cgit v1.2.3