commit 9738c420533c7eafad8d28a5154b925d7265d1bb
parent 7c4f92ff090440b0b595ac8eee05cd5969aec9be
Author: Antoine A <>
Date: Sat, 7 Jun 2025 15:06:34 +0200
common: fix conversion revert logic
Diffstat:
9 files changed, 85 insertions(+), 57 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt
@@ -88,7 +88,7 @@ fun bankConfig(configPath: Path?): BankConfig = BANK_CONFIG_SOURCE.fromFile(conf
/** Run [lambda] with access to a database conn pool */
suspend fun BankConfig.withDb(lambda: suspend (Database, BankConfig) -> Unit) {
- Database(dbCfg, regionalCurrency, fiatCurrency, payto).use { lambda(it, this) }
+ Database(dbCfg, regionalCurrency, fiatCurrency, regionalCurrencySpec, fiatCurrencySpec, payto).use { lambda(it, this) }
}
private fun TalerConfig.loadBankConfig(): BankConfig = section("libeufin-bank").run {
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt
@@ -113,8 +113,8 @@ class ConversionDAO(private val db: Database) {
}
/** Perform [direction] conversion of [amount] using in-db [function] */
- private suspend fun conversion(amount: TalerAmount, direction: String, function: String): ConversionResult = db.serializable(
- "SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM $function((?, ?)::taler_amount, ?, (0, 0)::taler_amount)"
+ private suspend fun conversionTo(amount: TalerAmount, direction: String): ConversionResult = db.serializable(
+ "SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM conversion_to((?, ?)::taler_amount, ?, (0, 0)::taler_amount)"
) {
bind(amount)
bind(direction)
@@ -128,13 +128,30 @@ class ConversionDAO(private val db: Database) {
}
}
}
+
+ private suspend fun conversionFrom(amount: TalerAmount, direction: String): ConversionResult = db.serializable(
+ "SELECT too_small, no_config, (converted).val AS amount_val, (converted).frac AS amount_frac FROM conversion_from((?, ?)::taler_amount, ?, (0, 0)::taler_amount, ?)"
+ ) {
+ bind(amount)
+ bind(direction)
+ bind((if (amount.currency == db.bankCurrency) db.fiatCurrencySpec!! else db.bankCurrencySpec).num_fractional_input_digits)
+ one {
+ when {
+ it.getBoolean("no_config") -> ConversionResult.MissingConfig
+ it.getBoolean("too_small") -> ConversionResult.ToSmall
+ else -> ConversionResult.Success(
+ it.getAmount("amount", if (amount.currency == db.bankCurrency) db.fiatCurrency!! else db.bankCurrency)
+ )
+ }
+ }
+ }
/** Convert [regional] amount to fiat using cashout rate */
- suspend fun toCashout(regional: TalerAmount): ConversionResult = conversion(regional, "cashout", "conversion_to")
+ suspend fun toCashout(regional: TalerAmount): ConversionResult = conversionTo(regional, "cashout")
/** Convert [fiat] amount to regional using cashin rate */
- suspend fun toCashin(fiat: TalerAmount): ConversionResult = conversion(fiat, "cashin", "conversion_to")
+ suspend fun toCashin(fiat: TalerAmount): ConversionResult = conversionTo(fiat, "cashin")
/** Convert [fiat] amount to regional using inverse cashout rate */
- suspend fun fromCashout(fiat: TalerAmount): ConversionResult = conversion(fiat, "cashout", "conversion_from")
+ suspend fun fromCashout(fiat: TalerAmount): ConversionResult = conversionFrom(fiat, "cashout")
/** Convert [regional] amount to fiat using inverse cashin rate */
- suspend fun fromCashin(regional: TalerAmount): ConversionResult = conversion(regional, "cashin", "conversion_from")
+ suspend fun fromCashin(regional: TalerAmount): ConversionResult = conversionFrom(regional, "cashin")
}
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
@@ -34,6 +34,8 @@ class Database(
dbConfig: DatabaseConfig,
internal val bankCurrency: String,
internal val fiatCurrency: String?,
+ internal val bankCurrencySpec: CurrencySpecification,
+ internal val fiatCurrencySpec: CurrencySpecification?,
internal val ctx: BankPaytoCtx
): DbPool(dbConfig, "libeufin-bank") {
// DAOs
diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt
@@ -339,12 +339,13 @@ class AmountTest {
}
}
- val revertStmt = conn.talerStatement("SELECT (result).val, (result).frac FROM conversion_revert_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode)")
- fun TalerAmount.revert(ratio: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero"): TalerAmount {
+ val revertStmt = conn.talerStatement("SELECT (result).val, (result).frac FROM conversion_revert_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode, ?)")
+ fun TalerAmount.revert(ratio: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero", digits: Int = 8): TalerAmount {
revertStmt.bind(this)
revertStmt.bind(ratio)
revertStmt.bind(tiny)
revertStmt.bind(roundingMode)
+ revertStmt.bind(digits)
return revertStmt.one {
TalerAmount(
it.getLong(1),
@@ -356,7 +357,7 @@ class AmountTest {
assertEquals(TalerAmount("EUR:6.41"), TalerAmount("EUR:30.0629").revert(DecimalNumber("4.69")))
assertEquals(TalerAmount("EUR:6.41"), TalerAmount("EUR:6.41000641").revert(DecimalNumber("1.000001")))
- assertEquals(TalerAmount("EUR:0.99999999"), TalerAmount("EUR:2.49999998").revert(DecimalNumber("2.5")))
+ assertEquals(TalerAmount("EUR:1"), TalerAmount("EUR:2.49999998").revert(DecimalNumber("2.5")))
assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999").revert(DecimalNumber("1")))
assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}"), TalerAmount("EUR:${TalerAmount.MAX_VALUE/4}").revert(DecimalNumber("0.25")))
assertException("ERROR: amount value overflowed") { TalerAmount(TalerAmount.MAX_VALUE/4, 0, "EUR").revert(DecimalNumber("0.24999999")) }
@@ -365,16 +366,19 @@ class AmountTest {
for (mode in sequenceOf("zero", "up", "nearest")) {
- for (tiny in sequenceOf("0.01", "0.00000001", "1", "5").map(::DecimalNumber)) {
+ for (tiny in sequenceOf("0.01", "0.00000001", "1", "2", "3", "5").map(::DecimalNumber)) {
for (amount in sequenceOf(10, 11, 12, 12, 14, 15, 16, 17, 18, 19).map { TalerAmount("EUR:$it") }) {
- for (ratio in sequenceOf("1", "0.01", "0.001", "0.00000001").map(::DecimalNumber)) {
- // Apply ratio
- val rounded = amount.apply(ratio, tiny, mode)
- // Revert ratio
- val revert = rounded.revert(ratio, tiny, mode)
- // Check applying ratio again give the same result
- val check = revert.apply(ratio, tiny, mode)
- assertEquals(rounded, check)
+ for (ratio in sequenceOf("1", "1.25", "1.26", "0.01", "0.001", "0.00000001").map(::DecimalNumber)) {
+ for (digits in sequenceOf(8, 2, 0)) {
+ // Apply ratio
+ val rounded = amount.apply(ratio, tiny, mode)
+ // Revert ratio
+ val revert = rounded.revert(ratio, tiny, mode, digits)
+ // Check applying ratio again give the same result
+ val check = revert.apply(ratio, tiny, mode)
+ println("$amount $rounded $revert $check $ratio $tiny $mode")
+ assertEquals(rounded, check)
+ }
}
}
}
diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/bank/src/test/kotlin/ConversionApiTest.kt
@@ -70,12 +70,12 @@ class ConversionApiTest {
// Check conversion to
client.get("/conversion-info/cashout-rate?amount_debit=KUDOS:1").assertOkJson<ConversionResponse> {
assertEquals(TalerAmount("KUDOS:1"), it.amount_debit)
- assertEquals(TalerAmount("EUR:1.24"), it.amount_credit)
+ assertEquals(TalerAmount("EUR:1.25"), it.amount_credit)
}
// Check conversion from
- client.get("/conversion-info/cashout-rate?amount_credit=EUR:1.247").assertOkJson<ConversionResponse> {
- assertEquals(TalerAmount("KUDOS:1"), it.amount_debit)
- assertEquals(TalerAmount("EUR:1.247"), it.amount_credit)
+ client.get("/conversion-info/cashout-rate?amount_credit=EUR:1.257").assertOkJson<ConversionResponse> {
+ assertEquals(TalerAmount("KUDOS:1.01"), it.amount_debit)
+ assertEquals(TalerAmount("EUR:1.257"), it.amount_credit)
}
// Too small
diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt
@@ -114,16 +114,16 @@ class StatsTest {
monitorTalerIn(6, "KUDOS:78.84")
cashout("KUDOS:3")
- monitorCashout(1, "KUDOS:3", "EUR:3.74")
+ monitorCashout(1, "KUDOS:3", "EUR:3.77")
cashout("KUDOS:7.6")
- monitorCashout(2, "KUDOS:10.6", "EUR:13.23")
+ monitorCashout(2, "KUDOS:10.6", "EUR:13.34")
cashout("KUDOS:12.3")
- monitorCashout(3, "KUDOS:22.9", "EUR:28.6")
+ monitorCashout(3, "KUDOS:22.9", "EUR:28.83")
monitorTalerIn(6, "KUDOS:78.84")
monitorTalerOut(3, "KUDOS:82.5")
monitorCashin(3, "KUDOS:55.94", "EUR:70")
- monitorCashout(3, "KUDOS:22.9", "EUR:28.6")
+ monitorCashout(3, "KUDOS:22.9", "EUR:28.83")
}
@Test
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
@@ -148,7 +148,7 @@ fun bankSetup(
"cashin_tiny_amount" to "KUDOS:0.01"
"cashin_rounding_mode" to "nearest"
"cashin_min_amount" to "EUR:0"
- "cashout_ratio" to "1.25"
+ "cashout_ratio" to "1.26"
"cashout_fee" to "EUR:0.003"
"cashout_tiny_amount" to "EUR:0.01"
"cashout_rounding_mode" to "zero"
diff --git a/common/src/main/kotlin/db/statement.kt b/common/src/main/kotlin/db/statement.kt
@@ -31,14 +31,21 @@ internal val logger: Logger = LoggerFactory.getLogger("libeufin-db")
class TalerStatement(internal val stmt: PreparedStatement): java.io.Closeable {
override fun close() {
+ // Close inner statement
+ stmt.close()
+ }
+
+ private fun consume() {
// Log warnings
var current = stmt.getWarnings()
while (current != null) {
- //logger.warning(current.message)
+ logger.warn(current.message)
current = current.getNextWarning()
}
- // Close inner statement
- stmt.close()
+
+ // Reset params
+ stmt.clearParameters()
+ idx=1
}
/* ----- Bindings helpers ----- */
@@ -117,8 +124,7 @@ class TalerStatement(internal val stmt: PreparedStatement): java.io.Closeable {
return try {
stmt.executeQuery()
} finally {
- stmt.clearParameters()
- idx=1
+ consume()
}
}
@@ -126,8 +132,7 @@ class TalerStatement(internal val stmt: PreparedStatement): java.io.Closeable {
return try {
stmt.executeUpdate()
} finally {
- stmt.clearParameters()
- idx=1
+ consume()
}
}
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -1838,7 +1838,6 @@ LANGUAGE plpgsql AS $$
DECLARE
amount_numeric NUMERIC(33, 8); -- 16 digit for val, 8 for frac and 1 for rounding error
tiny_numeric NUMERIC(24);
- rounding_error real;
BEGIN
-- Perform multiplication using big numbers
amount_numeric = (amount.val::numeric(24) * 100000000 + amount.frac::numeric(24)) * (ratio.val::numeric(24, 8) + ratio.frac::numeric(24, 8) / 100000000);
@@ -1853,15 +1852,11 @@ BEGIN
-- Round to tiny amounts
tiny_numeric = (tiny.val::numeric(24) * 100000000 + tiny.frac::numeric(24));
- amount_numeric = amount_numeric / tiny_numeric;
- rounding_error = (amount_numeric % 1)::real;
- amount_numeric = trunc(amount_numeric) * tiny_numeric;
-
- -- Apply rounding mode
- IF (rounding = 'nearest'::rounding_mode AND rounding_error >= 0.5)
- OR (rounding = 'up'::rounding_mode AND rounding_error > 0.0) THEN
- amount_numeric = amount_numeric + tiny_numeric;
- END IF;
+ case rounding
+ when 'zero' then amount_numeric = trunc(amount_numeric / tiny_numeric) * tiny_numeric;
+ when 'up' then amount_numeric = ceil(amount_numeric / tiny_numeric) * tiny_numeric;
+ when 'nearest' then amount_numeric = round(amount_numeric / tiny_numeric) * tiny_numeric;
+ end case;
-- Extract product parts
result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4);
@@ -1879,6 +1874,7 @@ CREATE FUNCTION conversion_revert_ratio(
,IN fee taler_amount
,IN tiny taler_amount -- Result is rounded to this amount
,IN rounding rounding_mode -- With this rounding mode
+ ,IN frac_digits INT
,OUT result taler_amount
,OUT bad_value BOOLEAN
)
@@ -1886,28 +1882,31 @@ LANGUAGE plpgsql AS $$
DECLARE
amount_numeric NUMERIC(33, 8); -- 16 digit for val, 8 for frac and 1 for rounding error
tiny_numeric NUMERIC(24);
- rounding_error real;
+ roundtrip BOOLEAN;
BEGIN
-- Apply fees
- amount_numeric = (amount.val::numeric(24) * 100000000 + amount.frac::numeric(24)) + (fee.val::numeric(24) * 100000000 + fee.frac::numeric(24));
+ amount_numeric = (amount.val::numeric(24) * 100000000 + amount.frac::numeric(24)) + (fee.val::numeric(24) * 100000000 + fee.frac::numeric(24));
-- Perform division using big numbers
amount_numeric = amount_numeric / (ratio.val::numeric(24, 8) + ratio.frac::numeric(24, 8) / 100000000);
- -- Round to tiny amounts
- tiny_numeric = (tiny.val::numeric(24) * 100000000 - tiny.frac::numeric(24));
- amount_numeric = amount_numeric / tiny_numeric;
- rounding_error = (amount_numeric % 1)::real;
- amount_numeric = trunc(amount_numeric) * tiny_numeric;
+ -- Round to input digits
+ tiny_numeric = power(10::numeric, 8 - frac_digits);
+ amount_numeric = trunc(amount_numeric / tiny_numeric) * tiny_numeric;
+
+ -- Extract division parts
+ result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4);
-- Recover potentially lost tiny amount during rounding
- IF (rounding = 'zero'::rounding_mode AND rounding_error > 0.0) THEN
+ -- There must be a clever way to compute this but I am a little limited with math
+ -- and revert ratio computation is not a hot function so I just use the apply ratio
+ -- function to be conservative and correct
+ SELECT ok INTO roundtrip FROM amount_left_minus_right((SELECT conversion_apply_ratio.result FROM conversion_apply_ratio(result, ratio, fee, tiny, rounding)), amount);
+ IF NOT roundtrip THEN
amount_numeric = amount_numeric + tiny_numeric;
+ result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4);
END IF;
- -- Extract division parts
- result = (trunc(amount_numeric / 100000000)::int8, (amount_numeric % 100000000)::int4);
-
IF (result.val > 1::INT8<<52) THEN
RAISE EXCEPTION 'amount value overflowed';
END IF;
@@ -1964,6 +1963,7 @@ CREATE FUNCTION conversion_from(
IN amount taler_amount,
IN direction TEXT,
IN custom_min_amount taler_amount,
+ IN frac_digits INT,
OUT converted taler_amount,
OUT too_small BOOLEAN,
OUT under_min BOOLEAN,
@@ -1987,7 +1987,7 @@ BEGIN
RETURN;
END IF;
SELECT (result).val, (result).frac INTO converted.val, converted.frac
- FROM conversion_revert_ratio(amount, at_ratio, out_fee, tiny_amount, mode);
+ FROM conversion_revert_ratio(amount, at_ratio, out_fee, tiny_amount, mode, frac_digits);
-- Check min amount
SELECT value['val']::int8, value['frac']::int4 INTO min_amount.val, min_amount.frac FROM config WHERE key=direction||'_min_amount';