commit cdb6712523aacb3e6f0a44d005405862e09fdc0e
parent a0e9cc4b58c55499abd0c2a85bbe7c2cc47abb1e
Author: Antoine A <>
Date: Mon, 20 Jan 2025 12:29:57 +0100
nexus: support admin balance adjust
Diffstat:
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