commit e4dd3a89023c391d55c50235aaeae057cff33392
parent 77360d083858789fa54f187ae3176d4bca1299ca
Author: Antoine A <>
Date: Mon, 30 Oct 2023 18:44:44 +0000
Fix conversion config and improve amount multiplication
Diffstat:
5 files changed, 101 insertions(+), 49 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
@@ -1546,17 +1546,21 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
} ?: throw internalServerError("No result from DB procedure stats_get_frame")
}
- suspend fun conversionUpdateConfig(cfg: ConversionInfo) = conn { conn ->
- val stmt = conn.prepareStatement("CALL conversion_config_update((?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount)")
- stmt.setLong(1, cfg.buy_at_ratio.value)
- stmt.setInt(2, cfg.buy_at_ratio.frac)
- stmt.setLong(3, cfg.sell_at_ratio.value)
- stmt.setInt(4, cfg.sell_at_ratio.frac)
- stmt.setLong(5, cfg.buy_in_fee.value)
- stmt.setInt(6, cfg.buy_in_fee.frac)
- stmt.setLong(7, cfg.sell_out_fee.value)
- stmt.setInt(8, cfg.sell_out_fee.frac)
- stmt.executeUpdate()
+ suspend fun conversionUpdateConfig(cfg: ConversionInfo) = conn {
+ it.transaction { conn ->
+ val stmt = conn.prepareStatement("CALL config_set_amount(?, (?, ?)::taler_amount)")
+ for ((name, amount) in listOf(
+ Pair("buy_at_ratio", cfg.buy_at_ratio),
+ Pair("sell_at_ratio", cfg.sell_at_ratio),
+ Pair("buy_in_fee", cfg.buy_in_fee),
+ Pair("sell_out_fee", cfg.sell_out_fee)
+ )) {
+ stmt.setString(1, name)
+ stmt.setLong(2, amount.value)
+ stmt.setInt(3, amount.frac)
+ stmt.executeUpdate()
+ }
+ }
}
suspend fun conversionInternalToFiat(internalAmount: TalerAmount): TalerAmount = conn { conn ->
diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt
@@ -206,30 +206,58 @@ class AmountTest {
@Test
fun mul() = dbSetup { db ->
db.conn { conn ->
- val stmt = conn.prepareStatement("SELECT product.val, product.frac FROM amount_mul((?, ?)::taler_amount, (?, ?)::taler_amount, NULL) as product")
- operator fun TalerAmount.times(increment: TalerAmount): TalerAmount? {
- stmt.setLong(1, value)
- stmt.setInt(2, frac)
- stmt.setLong(3, increment.value)
- stmt.setInt(4, increment.frac)
+ val stmt = conn.prepareStatement("SELECT product.val, product.frac FROM amount_mul((?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode) as product")
+
+ fun mul(nb: TalerAmount, times: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "round-to-zero"): TalerAmount? {
+ stmt.setLong(1, nb.value)
+ stmt.setInt(2, nb.frac)
+ stmt.setLong(3, times.value)
+ stmt.setInt(4, times.frac)
+ stmt.setLong(5, tiny.value)
+ stmt.setInt(6, tiny.frac)
+ stmt.setString(7, roundingMode)
return stmt.oneOrNull {
TalerAmount(
it.getLong(1),
it.getInt(2),
- "EUR"
+ nb.currency
)
}!!
}
+ assertEquals(TalerAmount("EUR:30.0629"), mul(TalerAmount("EUR:6.41"), DecimalNumber("4.69")))
+ assertEquals(TalerAmount("EUR:6.41000641"), mul(TalerAmount("EUR:6.41"), DecimalNumber("1.000001")))
+ assertEquals(TalerAmount("EUR:2.49999998"), mul(TalerAmount("EUR:0.99999999"), DecimalNumber("2.5")))
+ assertEquals(TalerAmount("EUR:2.49999998"), mul(TalerAmount("EUR:0.99999999"), DecimalNumber("2.5")))
+ assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), mul(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), DecimalNumber("1")))
+ assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}"), mul(TalerAmount("EUR:${TalerAmount.MAX_VALUE/4}"), DecimalNumber("4")))
+ assertException("ERROR: amount value overflowed") { mul(TalerAmount(TalerAmount.MAX_VALUE/3, 0, "EUR"), DecimalNumber("3.00000001")) }
+ assertException("ERROR: amount value overflowed") { mul(TalerAmount((TalerAmount.MAX_VALUE+2)/2, 0, "EUR"), DecimalNumber("2")) }
+ assertException("ERROR: numeric field overflow") { mul(TalerAmount(Long.MAX_VALUE, 0, "EUR"), DecimalNumber("1")) }
- assertEquals(TalerAmount("EUR:6.41") * TalerAmount("EUR:4.69"), TalerAmount("EUR:30.0629"))
- assertEquals(TalerAmount("EUR:6.41") * TalerAmount("EUR:1.000001"), TalerAmount("EUR:6.41000641"))
- assertEquals(TalerAmount("EUR:0.99999999") * TalerAmount("EUR:2.5"), TalerAmount("EUR:2.49999997"))
- assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999") * TalerAmount("EUR:1"), TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"))
- assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE/4}") * TalerAmount("EUR:4"), TalerAmount("EUR:${TalerAmount.MAX_VALUE}"))
- assertException("ERROR: amount value overflowed") { TalerAmount(TalerAmount.MAX_VALUE/3, 0, "EUR") * TalerAmount(3, 1, "EUR") }
- assertException("ERROR: amount value overflowed") { TalerAmount((TalerAmount.MAX_VALUE+2)/2, 0, "EUR") * TalerAmount("EUR:2") }
- assertException("ERROR: numeric field overflow") { TalerAmount(Long.MAX_VALUE, 0, "EUR") * TalerAmount("EUR:1") }
+ // Check euro rounding mode
+ for ((mode, rounding) in listOf(
+ Pair("round-to-zero", listOf(Pair(1, listOf(10, 11, 12, 12, 14, 15, 16, 17, 18, 19)))),
+ Pair("round-up", listOf(Pair(1, listOf(10)), Pair(2, listOf(11, 12, 12, 14, 15, 16, 17, 18, 19)))),
+ Pair("round-to-nearest", listOf(Pair(1, listOf(10, 11, 12, 12, 14)), Pair(2, listOf(15, 16, 17, 18, 19))))
+ )) {
+ for ((rounded, amounts) in rounding) {
+ for (amount in amounts) {
+ assertEquals(TalerAmount("EUR:0.0$rounded"), mul(TalerAmount("EUR:$amount"), DecimalNumber("0.001001"), DecimalNumber("0.01"), mode))
+ }
+ }
+ }
+
+ // Check hungarian rounding
+ for ((rounded, amounts) in listOf(
+ Pair(10, listOf(10, 11, 12)),
+ Pair(15, listOf(13, 14, 15, 16, 17)),
+ Pair(20, listOf(18, 19)),
+ )) {
+ for (amount in amounts) {
+ assertEquals(TalerAmount("HUF:$rounded"), mul(TalerAmount("HUF:$amount"), DecimalNumber("1.01"), DecimalNumber("5"), "round-to-nearest"))
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt
@@ -29,10 +29,17 @@ import kotlin.experimental.inv
import kotlin.test.*
class DatabaseTest {
+ // Testing the helper that update conversion config
+ @Test
+ fun conversionConfig() = setup { db, ctx ->
+ // Check idempotent
+ db.conversionUpdateConfig(ctx.conversionInfo!!)
+ db.conversionUpdateConfig(ctx.conversionInfo!!)
+ }
// Testing the helper that creates the admin account.
@Test
- fun createAdminTest() = setup { db, ctx ->
+ fun createAdmin() = setup { db, ctx ->
// Create admin account
assert(maybeCreateAdminAccount(db, ctx))
// Checking idempotency
diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql
@@ -51,7 +51,7 @@ CREATE TYPE stat_timeframe_enum
AS ENUM ('hour', 'day', 'month', 'year');
CREATE TYPE rounding_mode
- AS ENUM ('nearest', 'up', 'down'); -- up is toward infinity and down toward zero
+ AS ENUM ('round-to-zero', 'round-up', 'round-to-nearest'); -- up is toward infinity and down toward zero
-- FIXME: comments on types (see exchange for example)!
@@ -262,4 +262,4 @@ CREATE TABLE IF NOT EXISTS config (
-- end of: Conversion
-COMMIT;
+COMMIT;
+\ No newline at end of file
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -34,20 +34,38 @@ COMMENT ON FUNCTION amount_add
CREATE OR REPLACE FUNCTION amount_mul(
IN a taler_amount
,IN b taler_amount
+ ,IN tiny taler_amount -- Product is rounded around the tiny amount
,IN rounding rounding_mode
,OUT product taler_amount
)
LANGUAGE plpgsql AS $$
DECLARE
- tmp NUMERIC(25, 9); -- 16 digit for val, 8 for frac and 1 for rounding error
- rounding_error INT2;
+ product_numeric NUMERIC(32, 8); -- 16 digit for val and 8 for frac
+ tiny_numeric NUMERIC;
+ rounding_error int2;
BEGIN
- tmp = (a.val::numeric(25, 9) + a.frac::numeric(25, 9) / 100000000) * (b.val::numeric(25, 9) + b.frac::numeric(25, 9) / 100000000);
- product = (trunc(tmp)::int8, (tmp * 1000000000 % 1000000000)::int4);
- rounding_error = product.frac % 10;
- product.frac = product.frac / 10;
- -- TODO handle rounding
- SELECT normalized.val, normalized.frac INTO product.val, product.frac FROM amount_normalize(product) as normalized;
+ -- Perform multiplication using big numbers
+ product_numeric = (a.val::numeric(24, 8) + a.frac::numeric(24, 8) / 100000000) * (b.val::numeric(24, 8) + b.frac::numeric(24, 8) / 100000000);
+
+ -- Round to tiny amounts
+ product_numeric = product_numeric * 100000000;
+ tiny_numeric = (tiny.val::numeric(32, 8) * 100000000 + tiny.frac::numeric(32, 8));
+ product_numeric = product_numeric / tiny_numeric;
+ rounding_error = trunc(product_numeric * 10 % 10)::int2;
+ product_numeric = trunc(product_numeric)*tiny_numeric;
+
+ -- Apply rounding mode
+ IF (rounding = 'round-to-nearest'::rounding_mode AND rounding_error >= 5)
+ OR (rounding = 'round-up'::rounding_mode AND rounding_error > 0) THEN
+ product_numeric = product_numeric + tiny_numeric;
+ END IF;
+
+ -- Extract product parts
+ product = (trunc(product_numeric / 100000000)::int8, (product_numeric % 100000000)::int4);
+
+ IF (product.val > 1::bigint<<52) THEN
+ RAISE EXCEPTION 'amount value overflowed';
+ END IF;
END $$;
COMMENT ON FUNCTION amount_mul
IS 'Returns the product of two amounts. It raises an exception when the resulting .val is larger than 2^52';
@@ -1252,17 +1270,13 @@ BEGIN
END LOOP;
END $$;
-CREATE OR REPLACE PROCEDURE conversion_config_update(
- IN buy_at_ratio taler_amount,
- IN sell_at_ratio taler_amount,
- IN buy_in_fee taler_amount,
- IN sell_out_fee taler_amount
+CREATE OR REPLACE PROCEDURE config_set_amount(
+ IN name TEXT,
+ IN amount taler_amount
)
LANGUAGE sql AS $$
- INSERT INTO config (key, value) VALUES ('buy_at_ratio', jsonb_build_object('val', buy_at_ratio.val, 'frac', buy_at_ratio.frac));
- INSERT INTO config (key, value) VALUES ('sell_at_ratio', jsonb_build_object('val', sell_at_ratio.val, 'frac', sell_at_ratio.frac));
- INSERT INTO config (key, value) VALUES ('buy_in_fee', jsonb_build_object('val', buy_in_fee.val, 'frac', buy_in_fee.frac));
- INSERT INTO config (key, value) VALUES ('sell_out_fee', jsonb_build_object('val', sell_out_fee.val, 'frac', sell_out_fee.frac));
+ INSERT INTO config (key, value) VALUES (name, jsonb_build_object('val', amount.val, 'frac', amount.frac))
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value
$$;
CREATE OR REPLACE FUNCTION conversion_internal_to_fiat(
@@ -1273,14 +1287,12 @@ LANGUAGE plpgsql AS $$
DECLARE
sell_at_ratio taler_amount;
sell_out_fee taler_amount;
- rounding_mode rounding_mode;
calculation_ok BOOLEAN;
BEGIN
SELECT value['val']::int8, value['frac']::int4 INTO sell_at_ratio.val, sell_at_ratio.frac FROM config WHERE key='sell_at_ratio';
SELECT value['val']::int8, value['frac']::int4 INTO sell_out_fee.val, sell_out_fee.frac FROM config WHERE key='sell_out_fee';
- rounding_mode = 'nearest'; -- TODO rounding error config
- SELECT product.val, product.frac INTO fiat_amount.val, fiat_amount.frac FROM amount_mul(internal_amount, sell_at_ratio, rounding_mode) as product;
+ SELECT product.val, product.frac INTO fiat_amount.val, fiat_amount.frac FROM amount_mul(internal_amount, sell_at_ratio, (0, 1)::taler_amount, 'round-to-zero'::rounding_mode) as product;
SELECT (diff).val, (diff).frac, ok INTO fiat_amount.val, fiat_amount.frac, calculation_ok FROM amount_left_minus_right(fiat_amount, sell_out_fee);
IF NOT calculation_ok THEN