libeufin

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

commit cdb6712523aacb3e6f0a44d005405862e09fdc0e
parent a0e9cc4b58c55499abd0c2a85bbe7c2cc47abb1e
Author: Antoine A <>
Date:   Mon, 20 Jan 2025 12:29:57 +0100

nexus: support admin balance adjust

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 26+++++++++++++++-----------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 3++-
Mcommon/src/main/kotlin/Subject.kt | 34++++++++++++++++++++++++++++++----
Mcommon/src/test/kotlin/SubjectTest.kt | 23++++++++++++++++++++---
Mnexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 4++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 23++++++++++++++++-------
Mnexus/src/test/kotlin/DatabaseTest.kt | 14+++++++++-----
Mtestbench/src/test/kotlin/IntegrationTest.kt | 2+-
9 files changed, 97 insertions(+), 36 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt @@ -199,7 +199,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { amount = req.amount, debitAccount = req.debit_account, subject = "Admin incoming ${req.reserve_pub}", - metadata = IncomingSubject(IncomingType.reserve, req.reserve_pub) + metadata = IncomingSubject.Reserve(req.reserve_pub) ) } post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth") { @@ -208,7 +208,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { amount = req.amount, debitAccount = req.debit_account, subject = "Admin incoming KYC:${req.account_pub}", - metadata = IncomingSubject(IncomingType.kyc, req.account_pub) + metadata = IncomingSubject.Kyc(req.account_pub) ) } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -115,18 +115,22 @@ class TransactionDAO(private val db: Database) { } else if (exchangeCreditor) { val bounceCause = runCatching { parseIncomingSubject(subject) }.fold( onSuccess = { metadata -> - val registered = conn.withStatement("CALL register_incoming(?, ?::taler_incoming_type, ?, ?)") { - setLong(1, creditRowId) - setString(2, metadata.type.name) - setBytes(3, metadata.key.raw) - setLong(4, creditAccountId) - executeProcedureViolation() - } - if (!registered) { - logger.warn("exchange account $creditAccountId received an incoming taler transaction $creditRowId with an already used reserve public key") - "reserve public key reuse" + if (metadata is IncomingSubject.AdminBalanceAdjust) { + "unsupported admin balance adjust" } else { - null + val registered = conn.withStatement("CALL register_incoming(?, ?::taler_incoming_type, ?, ?)") { + setLong(1, creditRowId) + setString(2, metadata.type.name) + setBytes(3, metadata.key.raw) + setLong(4, creditAccountId) + executeProcedureViolation() + } + if (!registered) { + logger.warn("exchange account $creditAccountId received an incoming taler transaction $creditRowId with an already used reserve public key") + "reserve public key reuse" + } else { + null + } } }, onFailure = { e -> diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -1331,6 +1331,7 @@ class CoreBankTransactionsApiTest { assertBalance("exchange", "+KUDOS:0") tx("merchant", "KUDOS:1", "exchange", "") // Bounce common to transaction tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction + tx("merchant", "KUDOS:1", "exchange", "ADMIN BALANCE ADJUST") // Bounce admin balance adjust val reservePub = EddsaPublicKey.randEdsaKey() tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Accept incoming tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Bounce reserve_pub reuse diff --git a/common/src/main/kotlin/Subject.kt b/common/src/main/kotlin/Subject.kt @@ -21,7 +21,22 @@ package tech.libeufin.common import org.bouncycastle.math.ec.rfc8032.Ed25519 -data class IncomingSubject(val type: IncomingType, val key: EddsaPublicKey) +sealed interface IncomingSubject { + data class Reserve(val reserve_pub: EddsaPublicKey): IncomingSubject + data class Kyc(val account_pub: EddsaPublicKey): IncomingSubject + data object AdminBalanceAdjust: IncomingSubject + + val type: IncomingType get() = when (this) { + is Reserve -> IncomingType.reserve + is Kyc -> IncomingType.kyc + AdminBalanceAdjust -> throw IllegalStateException("Admin balance adjust") + } + val key: EddsaPublicKey get() = when (this) { + is Reserve -> this.reserve_pub + is Kyc -> this.account_pub + AdminBalanceAdjust -> throw IllegalStateException("Admin balance adjust") + } +} /** Base32 quality by proximity to spec and error probability */ private enum class Base32Quality { @@ -57,6 +72,7 @@ private enum class Base32Quality { private data class Candidate(val subject: IncomingSubject, val quality: Base32Quality) +private const val ADMIN_BALANCE_ADJUST = "ADMINBALANCEADJUST" private const val KEY_SIZE = 52; private const val KYC_SIZE = KEY_SIZE + 3; private val ALPHA_NUMBERIC_PATTERN = Regex("[0-9a-zA-Z]*") @@ -75,6 +91,11 @@ fun parseIncomingSubject(subject: String): IncomingSubject { fun parseSingle(str: String): Candidate? { // Check key type val (isKyc, raw) = when (str.length) { + ADMIN_BALANCE_ADJUST.length -> if (str.equals(ADMIN_BALANCE_ADJUST, ignoreCase = true)) { + return Candidate(IncomingSubject.AdminBalanceAdjust, Base32Quality.UpperStandard) + } else { + return null + } KEY_SIZE -> Pair(false, str) KYC_SIZE -> if (str.startsWith("KYC")) { Pair(true, str.substring(3)) @@ -95,8 +116,9 @@ fun parseIncomingSubject(subject: String): IncomingSubject { } val quality = Base32Quality.measure(raw); - val type = if (isKyc) IncomingType.kyc else IncomingType.reserve - return Candidate(IncomingSubject(type, key), quality) + + val subject = if (isKyc) IncomingSubject.Kyc(key) else IncomingSubject.Reserve(key) + return Candidate(subject, quality) } // Find and concatenate valid parts of a keys @@ -122,7 +144,11 @@ fun parseIncomingSubject(subject: String): IncomingSubject { val slice = concatenated.substring(range) parseSingle(slice)?.let { other -> if (best != null) { - if (other.quality > best.quality // We prefer high quality keys + if (best.subject is IncomingSubject.AdminBalanceAdjust) { + if (other.subject !is IncomingSubject.AdminBalanceAdjust) { + throw Exception("Found multiple subject kind") + } + } else if (other.quality > best.quality // We prefer high quality keys || ( // We prefer prefixed keys over reserve keys best.subject.type == IncomingType.reserve && (other.subject.type == IncomingType.kyc || other.subject.type == IncomingType.wad) diff --git a/common/src/test/kotlin/SubjectTest.kt b/common/src/test/kotlin/SubjectTest.kt @@ -39,7 +39,11 @@ class SubjectTest { val (mixedL, mixedR) = mixed.chunked(mixed.length / 2 + 1) val other_standard = "$prefix$other" val other_mixed = "${prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60" - val key = IncomingSubject(ty, EddsaPublicKey(key)) + val key = if (ty == IncomingType.reserve) { + IncomingSubject.Reserve(EddsaPublicKey(key)) + } else { + IncomingSubject.Kyc(EddsaPublicKey(key)) + } // Check succeed if standard or mixed for (case in sequenceOf(standard, mixed)) { @@ -120,6 +124,19 @@ class SubjectTest { } } } + + // Admin balance adjust + for (subject in sequenceOf( + "ADMIN BALANCE ADJUST", + "ADMIN:BALANCE:ADJUST", + "AdminBalanceAdjust", + "ignore aDmIn:BaLaNCe AdJUsT" + )) { + assertEquals( + IncomingSubject.AdminBalanceAdjust, + parseIncomingSubject(subject) + ) + } } /** Test parsing logic using real use case */ @@ -133,7 +150,7 @@ class SubjectTest { "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG" to "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG" )) { assertEquals( - IncomingSubject(IncomingType.reserve, EddsaPublicKey(key)), + IncomingSubject.Reserve(EddsaPublicKey(key)), parseIncomingSubject(subject) ) } @@ -142,7 +159,7 @@ class SubjectTest { "KYC JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZ FPW4YC3WJ2DWSJT70" to "JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZFPW4YC3WJ2DWSJT70" )) { assertEquals( - IncomingSubject(IncomingType.kyc, EddsaPublicKey(key)), + IncomingSubject.Kyc(EddsaPublicKey(key)), parseIncomingSubject(subject) ) } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -139,7 +139,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir amount = req.amount, debitAccount = req.debit_account, subject = "Manual incoming ${req.reserve_pub}", - metadata = IncomingSubject(IncomingType.reserve, req.reserve_pub) + metadata = IncomingSubject.Reserve(req.reserve_pub) ) } post("/taler-wire-gateway/admin/add-kycauth") { @@ -148,7 +148,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir amount = req.amount, debitAccount = req.debit_account, subject = "Manual incoming KYC:${req.account_pub}", - metadata = IncomingSubject(IncomingType.kyc, req.account_pub) + metadata = IncomingSubject.Kyc(req.account_pub) ) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -134,13 +134,22 @@ suspend fun registerIncomingPayment( } runCatching { parseIncomingSubject(payment.subject) }.fold( onSuccess = { metadata -> - when (val res = db.payment.registerTalerableIncoming(payment, metadata)) { - IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") - is IncomingRegistrationResult.Success -> { - if (res.new) { - logger.info("$payment") - } else { - logger.debug("{} already seen", payment) + if (metadata is IncomingSubject.AdminBalanceAdjust) { + val res = db.payment.registerIncoming(payment) + if (res.new) { + logger.info("$payment admin balance adjust") + } else { + logger.debug("{} already seen admin balance adjust", payment) + } + } else { + when (val res = db.payment.registerTalerableIncoming(payment, metadata)) { + IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") + is IncomingRegistrationResult.Success -> { + if (res.new) { + logger.info("$payment") + } else { + logger.debug("{} already seen", payment) + } } } } diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -226,10 +226,14 @@ class IncomingPaymentsTest { registerIncomingPayment(db, cfg, genInPay("another $subject")) db.checkInCount(3, 2, 1) + // Admin balance adjust is ignored + registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST")) + db.checkInCount(4, 2, 1) + // Different medata with missing id is ignored registerIncomingPayment(db, cfg, incomingMissingId.copy(amount = TalerAmount("KUDOS:9"))) registerIncomingPayment(db, cfg, incomingMissingId.copy(subject = "another $subject")) - db.checkInCount(3, 2, 1) + db.checkInCount(4, 2, 1) // Recover bank ID when metadata match registerIncomingPayment(db, cfg, incoming) @@ -237,15 +241,15 @@ class IncomingPaymentsTest { // Idempotent registerIncomingPayment(db, cfg, incoming) - db.checkInCount(3, 2, 1) + db.checkInCount(4, 2, 1) // Missing ID is ignored registerIncomingPayment(db, cfg, incomingMissingId) - db.checkInCount(3, 2, 1) + db.checkInCount(4, 2, 1) // Other ID is bounced known that we know the id registerIncomingPayment(db, cfg, incomingMissingId.copy(bankId = "NEW")) - db.checkInCount(4, 3, 1) + db.checkInCount(5, 3, 1) } // Test creating an incoming kyc taler transaction without and ID and reconcile it later again diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -217,7 +217,7 @@ class IntegrationTest { }.assertNoContent() assertException("ERROR: cashin failed: admin balance insufficient") { - db.payment.registerTalerableIncoming(reservePayment, IncomingSubject(IncomingType.reserve, reservePub)) + db.payment.registerTalerableIncoming(reservePayment, IncomingSubject.Reserve(reservePub)) } // Allow admin debt