libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit e4dd3a89023c391d55c50235aaeae057cff33392
parent 77360d083858789fa54f187ae3176d4bca1299ca
Author: Antoine A <>
Date:   Mon, 30 Oct 2023 18:44:44 +0000

Fix conversion config and improve amount multiplication

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 26+++++++++++++++-----------
Mbank/src/test/kotlin/AmountTest.kt | 58+++++++++++++++++++++++++++++++++++++++++++---------------
Mbank/src/test/kotlin/DatabaseTest.kt | 9++++++++-
Mdatabase-versioning/libeufin-bank-0001.sql | 5+++--
Mdatabase-versioning/libeufin-bank-procedures.sql | 52++++++++++++++++++++++++++++++++--------------------
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