libeufin

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

commit 715ffba34e0d73c3b4419e7ea1b83c4e1e8f918d
parent cd98976b6472a8e8bf5eecb0e1ee0f7b67445350
Author: Antoine A <>
Date:   Wed, 24 Jul 2024 12:34:26 +0200

common: support KYC taler incoming transaction

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 39++++++++++++++++++++++++++++++++-------
Mbank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt | 54++++++++++++++++++++++++++++++++++++------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 20++++++++++++++++----
Mbank/src/test/kotlin/StatsTest.kt | 4++++
Mbank/src/test/kotlin/WireGatewayApiTest.kt | 150+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mbank/src/test/kotlin/helpers.kt | 12++++++++++++
Mcommon/src/main/kotlin/Client.kt | 4++--
Mcommon/src/main/kotlin/Constants.kt | 2+-
Mcommon/src/main/kotlin/TalerMessage.kt | 56++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcommon/src/main/kotlin/TxMedatada.kt | 15++++++++++-----
Mcommon/src/test/kotlin/TxMedataTest.kt | 7+++----
Mdatabase-versioning/libeufin-bank-0007.sql | 19++++++++++++++++++-
Mdatabase-versioning/libeufin-bank-drop.sql | 2+-
Mdatabase-versioning/libeufin-bank-procedures.sql | 52+++++++++++++++++++++++++++++++++-------------------
Mdatabase-versioning/libeufin-conversion-setup.sql | 5+++++
Mdatabase-versioning/libeufin-nexus-0001.sql | 1-
Adatabase-versioning/libeufin-nexus-0006.sql | 41+++++++++++++++++++++++++++++++++++++++++
Mdatabase-versioning/libeufin-nexus-drop.sql | 2+-
Mdatabase-versioning/libeufin-nexus-procedures.sql | 71++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 42++++++++++++++++++++++++++++++++----------
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 4++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt | 34++++++++++++++++++++++------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 18+++++++++++++++---
Mnexus/src/test/kotlin/DatabaseTest.kt | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mnexus/src/test/kotlin/WireGatewayApiTest.kt | 147++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mnexus/src/test/kotlin/helpers.kt | 48+++++++++++++++++++++++++++++++++++++++++-------
Mnexus/src/test/kotlin/routines.kt | 6------
Mtestbench/src/test/kotlin/IntegrationTest.kt | 68+++++++++++++++++++++++++++++++++++++++++++-------------------------
Mtestbench/src/test/kotlin/MigrationTest.kt | 3+++
29 files changed, 730 insertions(+), 289 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 @@ -112,14 +112,21 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { } } authAdmin(db, TokenScope.readwrite) { - post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { - val req = call.receive<AddIncomingRequest>() - ctx.checkRegionalCurrency(req.amount) + suspend fun ApplicationCall.addIncoming( + amount: TalerAmount, + debitAccount: Payto, + subject: String, + metadata: TalerIncomingMetadata + ) { + ctx.checkRegionalCurrency(amount) val timestamp = Instant.now() val res = db.exchange.addIncoming( - req = req, + amount = amount, + debitAccount = debitAccount, + subject = subject, login = username, - timestamp = timestamp + timestamp = timestamp, + metadata = metadata ) when (res) { is AddIncomingResult.UnknownExchange -> throw unknownAccount(username) @@ -128,7 +135,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE ) is AddIncomingResult.UnknownDebtor -> throw conflict( - "Debtor account ${req.debit_account} was not found", + "Debtor account $debitAccount was not found", TalerErrorCode.BANK_UNKNOWN_DEBTOR ) is AddIncomingResult.BothPartyAreExchange -> throw conflict( @@ -143,7 +150,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { "Insufficient balance for debitor", TalerErrorCode.BANK_UNALLOWED_DEBIT ) - is AddIncomingResult.Success -> call.respond( + is AddIncomingResult.Success -> this.respond( AddIncomingResponse( timestamp = TalerProtocolTimestamp(timestamp), row_id = res.id @@ -151,5 +158,23 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { ) } } + post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { + val req = call.receive<AddIncomingRequest>() + call.addIncoming( + amount = req.amount, + debitAccount = req.debit_account, + subject = "Manual incoming ${req.reserve_pub}", + metadata = TalerIncomingMetadata(TalerIncomingType.reserve, req.reserve_pub) + ) + } + post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth") { + val req = call.receive<AddKycauthRequest>() + call.addIncoming( + amount = req.amount, + debitAccount = req.debit_account, + subject = "Manual incoming KYC:${req.account_pub}", + metadata = TalerIncomingMetadata(TalerIncomingType.kyc, req.account_pub) + ) + } } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -19,6 +19,7 @@ package tech.libeufin.bank.db +import tech.libeufin.bank.* import tech.libeufin.common.* import tech.libeufin.common.db.* import java.time.Instant @@ -30,7 +31,7 @@ class ExchangeDAO(private val db: Database) { params: HistoryParams, exchangeId: Long, ctx: BankPaytoCtx - ): List<IncomingReserveTransaction> + ): List<IncomingBankTransaction> = db.poolHistory(params, exchangeId, db::listenIncoming, """ SELECT bank_transaction_id @@ -39,19 +40,32 @@ class ExchangeDAO(private val db: Database) { ,(amount).frac AS amount_frac ,debtor_payto_uri ,debtor_name + ,type ,reserve_pub + ,account_pub FROM taler_exchange_incoming AS tfr JOIN bank_account_transactions AS txs ON bank_transaction=txs.bank_transaction_id WHERE """) { - IncomingReserveTransaction( - row_id = it.getLong("bank_transaction_id"), - date = it.getTalerTimestamp("transaction_date"), - amount = it.getAmount("amount", db.bankCurrency), - debit_account = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx), - reserve_pub = EddsaPublicKey(it.getBytes("reserve_pub")), - ) + val type = it.getEnum<TalerIncomingType>("type") + when (type) { + TalerIncomingType.reserve -> IncomingReserveTransaction( + row_id = it.getLong("bank_transaction_id"), + date = it.getTalerTimestamp("transaction_date"), + amount = it.getAmount("amount", db.bankCurrency), + debit_account = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx), + reserve_pub = EddsaPublicKey(it.getBytes("reserve_pub")), + ) + TalerIncomingType.kyc -> IncomingKycAuthTransaction( + row_id = it.getLong("bank_transaction_id"), + date = it.getTalerTimestamp("transaction_date"), + amount = it.getAmount("amount", db.bankCurrency), + debit_account = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx), + account_pub = EddsaPublicKey(it.getBytes("account_pub")), + ) + TalerIncomingType.wad -> throw UnsupportedOperationException() + } } /** Query [exchangeId] history of taler outgoing transactions */ @@ -163,9 +177,12 @@ class ExchangeDAO(private val db: Database) { /** Add a new taler incoming transaction */ suspend fun addIncoming( - req: AddIncomingRequest, + amount: TalerAmount, + debitAccount: Payto, + subject: String, login: String, - timestamp: Instant + timestamp: Instant, + metadata: TalerIncomingMetadata ): AddIncomingResult = db.serializable( """ SELECT @@ -178,19 +195,20 @@ class ExchangeDAO(private val db: Database) { ,out_tx_row_id FROM taler_add_incoming ( - ?, ?, - (?,?)::taler_amount, - ?, ?, ? + ?, ?, (?,?)::taler_amount, + ?, ?, ?, ?::taler_incoming_type ); """ ) { - setBytes(1, req.reserve_pub.raw) - setString(2, "Manual incoming ${req.reserve_pub}") - setLong(3, req.amount.value) - setInt(4, req.amount.frac) - setString(5, req.debit_account.canonical) + println(metadata) + setBytes(1, metadata.key.raw) + setString(2, subject) + setLong(3, amount.value) + setInt(4, amount.frac) + setString(5, debitAccount.canonical) setString(6, login) setLong(7, timestamp.micros()) + setString(8, metadata.type.name) one { when { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -26,6 +26,7 @@ import tech.libeufin.bank.TransactionDirection import tech.libeufin.common.* import tech.libeufin.common.db.* import java.time.Instant +import java.sql.Types private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-tx-dao") @@ -114,10 +115,21 @@ class TransactionDAO(private val db: Database) { logger.warn("exchange account $exchangeDebtor sent a manual transaction to exchange account $exchangeCreditor, this should never happens and is not bounced to prevent bouncing loop, may fail in the future") } else if (exchangeCreditor) { val bounceCause = runCatching { parseIncomingTxMetadata(subject) }.fold( - onSuccess = { reservePub -> - val registered = conn.withStatement("CALL register_incoming(?, ?)") { - setBytes(1, reservePub.raw) - setLong(2, creditRowId) + onSuccess = { metadata -> + val registered = conn.withStatement("CALL register_incoming(?, ?::taler_incoming_type, ?, ?)") { + setLong(1, creditRowId) + setString(2, metadata.type.name) + when (metadata.type) { + TalerIncomingType.reserve -> { + setBytes(3, metadata.key.raw) + setNull(4, Types.BINARY) + } + TalerIncomingType.kyc -> { + setNull(3, Types.BINARY) + setBytes(4, metadata.key.raw) + } + TalerIncomingType.wad -> throw UnsupportedOperationException() + } executeProcedureViolation() } if (!registered) { diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -91,6 +91,10 @@ class StatsTest { monitorTalerIn(2, "KUDOS:10.6") addIncoming("KUDOS:12.3") monitorTalerIn(3, "KUDOS:22.9") + + // KYC are ignored + addKyc("KUDOS:3") + monitorTalerIn(3, "KUDOS:22.9") transfer("KUDOS:10.0") monitorTalerOut(1, "KUDOS:10.0") diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2024 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 @@ -18,8 +18,10 @@ */ import io.ktor.http.* +import io.ktor.server.testing.* import org.junit.Test import tech.libeufin.common.* +import kotlin.test.* class WireGatewayApiTest { // GET /accounts/{USERNAME}/taler-wire-gateway/config @@ -131,28 +133,27 @@ class WireGatewayApiTest { url = "/accounts/exchange/taler-wire-gateway/history/incoming", ids = { it.incoming_transactions.map { it.row_id } }, registered = listOf( - { - // Transactions using clean add incoming logic - addIncoming("KUDOS:10") - }, - { - // Transactions using raw bank transaction logic - tx("merchant", "KUDOS:10", "exchange", "history test with ${ShortHashCode.rand()} reserve pub") - }, - { - // Transaction using withdraw logic - withdrawal("KUDOS:9") - } + // Reserve transactions using clean add incoming logic + { addIncoming("KUDOS:10") }, + + // Reserve transactions using raw bank transaction logic + { tx("merchant", "KUDOS:10", "exchange", "history test with ${ShortHashCode.rand()} reserve pub") }, + + // Reserve transactions using withdraw logic + { withdrawal("KUDOS:9") }, + + // KYC transaction using clean add incoming logic + { addKyc("KUDOS:2") }, + + // KYC transactions using raw bank transaction logic + { tx("merchant", "KUDOS:2", "exchange", "history test with KYC:${ShortHashCode.rand()} account pub") }, ), ignored = listOf( - { - // Ignore malformed incoming transaction - tx("merchant", "KUDOS:10", "exchange", "ignored") - }, - { - // Ignore malformed outgoing transaction - tx("exchange", "KUDOS:10", "merchant", "ignored") - } + // Ignore malformed incoming transaction + { tx("merchant", "KUDOS:10", "exchange", "ignored") }, + + // Ignore malformed outgoing transaction + { tx("exchange", "KUDOS:10", "merchant", "ignored") }, ) ) } @@ -166,88 +167,123 @@ class WireGatewayApiTest { url = "/accounts/exchange/taler-wire-gateway/history/outgoing", ids = { it.outgoing_transactions.map { it.row_id } }, registered = listOf( - { - // Transactions using clean add incoming logic - transfer("KUDOS:10") - } + // Transactions using clean add incoming logic + { transfer("KUDOS:10") } ), ignored = listOf( - { - // gnore manual incoming transaction - tx("exchange", "KUDOS:10", "merchant", "${ShortHashCode.rand()} http://exchange.example.com/") - }, - { - // Ignore malformed incoming transaction - tx("merchant", "KUDOS:10", "exchange", "ignored") - }, - { - // Ignore malformed outgoing transaction - tx("exchange", "KUDOS:10", "merchant", "ignored") - } + // Ignore manual outgoing transaction + { tx("exchange", "KUDOS:10", "merchant", "${ShortHashCode.rand()} http://exchange.example.com/") }, + + // Ignore malformed incoming transaction + { tx("merchant", "KUDOS:10", "exchange", "ignored") }, + + // Ignore malformed outgoing transaction + { tx("exchange", "KUDOS:10", "merchant", "ignored") }, ) ) } - // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming - @Test - fun addIncoming() = bankSetup { + suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: TalerIncomingType) { + val (path, key) = when (type) { + TalerIncomingType.reserve -> Pair("add-incoming", "reserve_pub") + TalerIncomingType.kyc -> Pair("add-kycauth", "account_pub") + TalerIncomingType.wad -> throw UnsupportedOperationException() + } val valid_req = obj { "amount" to "KUDOS:44" - "reserve_pub" to EddsaPublicKey.rand() + key to EddsaPublicKey.rand() "debit_account" to merchantPayto.canonical } - authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req, requireAdmin = true) + authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/$path", valid_req, requireAdmin = true) // Checking exchange debt constraint. - client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) // Giving debt allowance and checking the OK case. setMaxDebt("merchant", "KUDOS:1000") - client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) }.assertOk() - // Trigger conflict due to reused reserve_pub - client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { - json(valid_req) - }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) + if (type == TalerIncomingType.reserve) { + // Trigger conflict due to reused reserve_pub + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) + } else if (type == TalerIncomingType.kyc) { + // Non conflict on reuse + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertOk() + } // Currency mismatch - client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) { "amount" to "EUR:33" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) // Unknown account - client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) { - "reserve_pub" to EddsaPublicKey.rand() + key to EddsaPublicKey.rand() "debit_account" to unknownPayto } }.assertConflict(TalerErrorCode.BANK_UNKNOWN_DEBTOR) // Same account - client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) { - "reserve_pub" to EddsaPublicKey.rand() + key to EddsaPublicKey.rand() "debit_account" to exchangePayto } }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) // Bad BASE32 reserve_pub - client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) { - "reserve_pub" to "I love chocolate" + key to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len reserve_pub - client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) { - "reserve_pub" to randBase32Crockford(31) + key to randBase32Crockford(31) } }.assertBadRequest() } + + // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming + @Test + fun addIncoming() = bankSetup { + talerAddIncomingRoutine(TalerIncomingType.reserve) + } + + // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth + @Test + fun addKycAuth() = bankSetup { + talerAddIncomingRoutine(TalerIncomingType.kyc) + } + + @Test + fun addIncomingMix() = bankSetup { + addIncoming("KUDOS:1") + addKyc("KUDOS:2") + tx("merchant", "KUDOS:3", "exchange", "test with ${ShortHashCode.rand()} reserve pub") + tx("merchant", "KUDOS:4", "exchange", "test with KYC:${ShortHashCode.rand()} account pub") + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=25").assertOkJson<IncomingHistory> { + assertEquals(4, it.incoming_transactions.size) + it.incoming_transactions.forEachIndexed { i, tx -> + assertEquals(TalerAmount("KUDOS:${i+1}"), tx.amount) + if (i % 2 == 1) { + assertIs<IncomingKycAuthTransaction>(tx) + } else { + assertIs<IncomingReserveTransaction>(tx) + } + } + } + } } \ No newline at end of file diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -228,6 +228,18 @@ suspend fun ApplicationTestBuilder.addIncoming(amount: String) { }.assertOk() } +/** Perform a taler kyc transaction of [amount] from merchant to exchange */ +suspend fun ApplicationTestBuilder.addKyc(amount: String) { + client.post("/accounts/exchange/taler-wire-gateway/admin/add-kycauth") { + pwAuth("admin") + json { + "amount" to TalerAmount(amount) + "account_pub" to EddsaPublicKey.rand() + "debit_account" to merchantPayto + } + }.assertOk() +} + /** Perform a cashout operation of [amount] from customer */ suspend fun ApplicationTestBuilder.cashout(amount: String) { val res = client.postA("/accounts/customer/cashouts") { diff --git a/common/src/main/kotlin/Client.kt b/common/src/main/kotlin/Client.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2024 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 @@ -79,7 +79,7 @@ suspend inline fun <reified B> HttpResponse.assertAcceptedJson(lambda: (B) -> Un /* ----- Assert ----- */ suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: TalerErrorCode?): HttpResponse { - assertEquals(status, this.status, "$err") + assertEquals(status, this.status, if (err != null) "$err" else err) if (err != null) { val body = json<TalerError>() assertEquals(err.code, body.code) diff --git a/common/src/main/kotlin/Constants.kt b/common/src/main/kotlin/Constants.kt @@ -26,7 +26,7 @@ const val SERIALIZATION_RETRY: Int = 10 const val MAX_BODY_LENGTH: Int = 4 * 1024 // 4kB // API version -const val WIRE_GATEWAY_API_VERSION: String = "0:2:0" +const val WIRE_GATEWAY_API_VERSION: String = "1:0:0" const val REVENUE_API_VERSION: String = "0:0:0" // HTTP headers diff --git a/common/src/main/kotlin/TalerMessage.kt b/common/src/main/kotlin/TalerMessage.kt @@ -19,7 +19,13 @@ package tech.libeufin.common -import kotlinx.serialization.Serializable +import kotlinx.serialization.* + +enum class TalerIncomingType { + reserve, + kyc, + wad +} /** Response GET /taler-wire-gateway/config */ @Serializable @@ -62,23 +68,57 @@ data class AddIncomingResponse( val row_id: Long ) +/** Request POST /taler-wire-gateway/admin/add-kycauth */ +@Serializable +data class AddKycauthRequest( + val amount: TalerAmount, + val account_pub: EddsaPublicKey, + val debit_account: Payto +) + /** Request GET /taler-wire-gateway/history/incoming */ @Serializable data class IncomingHistory( - val incoming_transactions: List<IncomingReserveTransaction>, + val incoming_transactions: List<IncomingBankTransaction>, val credit_account: String ) /** Inner request GET /taler-wire-gateway/history/incoming */ @Serializable +sealed interface IncomingBankTransaction { + val row_id: Long + val date: TalerProtocolTimestamp + val amount: TalerAmount + val debit_account: String +}; +@Serializable +@SerialName("KYCAUTH") +data class IncomingKycAuthTransaction( + override val row_id: Long, + override val date: TalerProtocolTimestamp, + override val amount: TalerAmount, + override val debit_account: String, + val account_pub: EddsaPublicKey +): IncomingBankTransaction +@Serializable +@SerialName("RESERVE") data class IncomingReserveTransaction( - val type: String = "RESERVE", - val row_id: Long, // DB row ID of the payment. - val date: TalerProtocolTimestamp, - val amount: TalerAmount, - val debit_account: String, + override val row_id: Long, + override val date: TalerProtocolTimestamp, + override val amount: TalerAmount, + override val debit_account: String, val reserve_pub: EddsaPublicKey -) +): IncomingBankTransaction +@Serializable +@SerialName("WAD") +data class IncomingWadTransaction( + override val row_id: Long, + override val date: TalerProtocolTimestamp, + override val amount: TalerAmount, + override val debit_account: String, + val origin_exchange_url: String, + val wad_id: String // TODO 24 bytes Base32 +): IncomingBankTransaction /** Request GET /taler-wire-gateway/history/outgoing */ @Serializable diff --git a/common/src/main/kotlin/TxMedatada.kt b/common/src/main/kotlin/TxMedatada.kt @@ -16,32 +16,37 @@ * License along with LibEuFin; see the file COPYING. If not, see * <http://www.gnu.org/licenses/> */ + package tech.libeufin.common -private val BASE32_32B_UPPER_PATTERN = Regex("[0-9A-Z]{52}") -private val BASE32_32B_PATTERN = Regex("[a-z0-9A-Z]{52}") +private val BASE32_32B_UPPER_PATTERN = Regex("(KYC:)?([0-9A-Z]{52})") +private val BASE32_32B_PATTERN = Regex("(KYC:)?([a-z0-9A-Z]{52})") private val CLEAN_PATTERN = Regex(" ?[\\n\\-\\+] ?") +data class TalerIncomingMetadata(val type: TalerIncomingType, val key: EddsaPublicKey) + /** * Extract the reserve public key from an incoming Taler transaction subject * * We first try to match an uppercase key then a lowercase key. If none are * found we clean the subject and retry. **/ -fun parseIncomingTxMetadata(subject: String): EddsaPublicKey { +fun parseIncomingTxMetadata(subject: String): TalerIncomingMetadata { /** * Extract the reserve public key from [subject] using [pattern] * * Return null if found none and throw if find too many **/ - fun run(subject: String, pattern: Regex): EddsaPublicKey? { + fun run(subject: String, pattern: Regex): TalerIncomingMetadata? { val matches = pattern.findAll(subject).iterator() if (!matches.hasNext()) return null val match = matches.next() if (matches.hasNext()) { throw Exception("Found multiple reserve public key") } - return EddsaPublicKey(match.value) + val (prefix, key) = match.destructured + val type = if (prefix == "KYC:") TalerIncomingType.kyc else TalerIncomingType.reserve + return TalerIncomingMetadata(type, EddsaPublicKey(key)) } // Wire transfer subjects are generally small in size, and not diff --git a/common/src/test/kotlin/TxMedataTest.kt b/common/src/test/kotlin/TxMedataTest.kt @@ -17,13 +17,12 @@ * <http://www.gnu.org/licenses/> */ -import tech.libeufin.common.EddsaPublicKey -import tech.libeufin.common.parseIncomingTxMetadata +import tech.libeufin.common.* import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails -class TxMetadataTest{ +class TxMetadataTest { fun assertFailsMsg(msg: String, lambda: () -> Unit) { val failure = assertFails(lambda) assertEquals(msg, failure.message) @@ -39,7 +38,7 @@ class TxMetadataTest{ val mixedR = "y0vphyv0cyde6xbb0ympfxceg0" val otherUpper = "TEST6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0" val otherMixed = "test6rRSrvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0" - val key = EddsaPublicKey(upper) + val key = TalerIncomingMetadata(TalerIncomingType.reserve, EddsaPublicKey(upper)) // Check succeed if upper or mixed for (case in sequenceOf(upper, mixed)) { diff --git a/database-versioning/libeufin-bank-0007.sql b/database-versioning/libeufin-bank-0007.sql @@ -19,9 +19,26 @@ SELECT _v.register_patch('libeufin-bank-0007', NULL, NULL); SET search_path TO libeufin_bank; -- Make customer not null - -- Fill missing name with an empty string. All accounts created using the API already -- have a non-null name, so this only applies to accounts created manually with SQL. UPDATE customers SET name='' WHERE name is NULL; ALTER TABLE customers ALTER COLUMN name SET NOT NULL; + +-- Support all taler incoming transaction types +CREATE TYPE taler_incoming_type AS ENUM + ('reserve' ,'kyc', 'wad'); +ALTER TABLE taler_exchange_incoming + ADD type taler_incoming_type NOT NULL DEFAULT 'reserve', + ADD account_pub BYTEA CHECK (LENGTH(account_pub)=32), + ADD origin_exchange_url TEXT, + ADD wad_id BYTEA CHECK (LENGTH(wad_id)=24), + ALTER COLUMN reserve_pub DROP NOT NULL, + ADD CONSTRAINT incoming_polymorphism CHECK( + CASE type + WHEN 'reserve' THEN reserve_pub IS NOT NULL AND account_pub IS NULL AND origin_exchange_url IS NULL AND wad_id IS NULL + WHEN 'kyc' THEN reserve_pub IS NULL AND account_pub IS NOT NULL AND origin_exchange_url IS NULL AND wad_id IS NULL + WHEN 'wad' THEN reserve_pub IS NULL AND account_pub IS NULL AND origin_exchange_url IS NOT NULL AND wad_id IS NOT NULL + END + ); +ALTER TABLE taler_exchange_incoming ALTER COLUMN type DROP DEFAULT; COMMIT; diff --git a/database-versioning/libeufin-bank-drop.sql b/database-versioning/libeufin-bank-drop.sql @@ -5,7 +5,7 @@ $do$ DECLARE patch text; BEGIN - IF EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name='_v') THEN + IF EXISTS(SELECT FROM information_schema.schemata WHERE schema_name='_v') THEN FOR patch IN SELECT patch_name FROM _v.patches WHERE patch_name LIKE 'libeufin_bank_%' LOOP PERFORM _v.unregister_patch(patch); END LOOP; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -570,8 +570,10 @@ COMMENT ON PROCEDURE register_outgoing IS 'Register a bank transaction as a taler outgoing transaction and announce it'; CREATE PROCEDURE register_incoming( + IN in_tx_row_id INT8, + IN in_type taler_incoming_type, IN in_reserve_pub BYTEA, - IN in_tx_row_id INT8 + IN in_account_pub BYTEA ) LANGUAGE plpgsql AS $$ DECLARE @@ -582,17 +584,24 @@ BEGIN INSERT INTO taler_exchange_incoming ( reserve_pub, - bank_transaction + account_pub, + bank_transaction, + type ) VALUES ( in_reserve_pub, - in_tx_row_id + in_account_pub, + in_tx_row_id, + in_type ); --- update stats +-- Get bank transaction info SELECT (amount).val, (amount).frac, bank_account_id INTO local_amount.val, local_amount.frac, local_bank_account_id FROM bank_account_transactions WHERE bank_transaction_id=in_tx_row_id; -CALL stats_register_payment('taler_in', NULL, local_amount, null); --- notify new transaction +-- Update stats +IF in_type = 'reserve' THEN + CALL stats_register_payment('taler_in', NULL, local_amount, null); +END IF; +-- Notify new incoming transaction PERFORM pg_notify('incoming_tx', local_bank_account_id || ' ' || in_tx_row_id); END $$; COMMENT ON PROCEDURE register_incoming @@ -688,12 +697,13 @@ END $$; COMMENT ON FUNCTION taler_transfer IS 'Create an outgoing taler transaction and register it'; CREATE FUNCTION taler_add_incoming( - IN in_reserve_pub BYTEA, + IN in_key BYTEA, IN in_subject TEXT, IN in_amount taler_amount, IN in_debit_account_payto TEXT, IN in_username TEXT, IN in_timestamp INT8, + IN in_type taler_incoming_type, -- Error status OUT out_creditor_not_found BOOLEAN, OUT out_creditor_not_exchange BOOLEAN, @@ -710,12 +720,13 @@ exchange_bank_account_id INT8; sender_bank_account_id INT8; BEGIN -- Check conflict -SELECT true FROM taler_exchange_incoming WHERE reserve_pub = in_reserve_pub -UNION ALL -SELECT true FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub - INTO out_reserve_pub_reuse; -IF out_reserve_pub_reuse THEN - RETURN; +IF in_type = 'reserve'::taler_incoming_type THEN + SELECT EXISTS(SELECT FROM taler_exchange_incoming WHERE reserve_pub = in_key) OR + EXISTS(SELECT FROM taler_withdrawal_operations WHERE reserve_pub = in_key) + INTO out_reserve_pub_reuse; + IF out_reserve_pub_reuse THEN + RETURN; + END IF; END IF; -- Find exchange bank account id SELECT @@ -760,7 +771,11 @@ IF out_debitor_balance_insufficient THEN RETURN; END IF; -- Register incoming transaction -CALL register_incoming(in_reserve_pub, out_tx_row_id); +CASE in_type + WHEN 'reserve' THEN CALL register_incoming(out_tx_row_id, 'reserve', in_key, NULL); + WHEN 'kyc' THEN CALL register_incoming(out_tx_row_id, 'kyc', NULL, in_key); + ELSE RAISE EXCEPTION 'Unsupported incoming type %', in_type; +END CASE; END $$; COMMENT ON FUNCTION taler_add_incoming IS 'Create an incoming taler transaction and register it'; @@ -968,9 +983,8 @@ END IF; IF not_selected THEN -- Check reserve_pub reuse - SELECT true FROM taler_exchange_incoming WHERE reserve_pub = in_reserve_pub - UNION ALL - SELECT true FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub + SELECT EXISTS(SELECT FROM taler_exchange_incoming WHERE reserve_pub = in_reserve_pub) OR + EXISTS(SELECT FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub) INTO out_reserve_pub_reuse; IF out_reserve_pub_reuse THEN RETURN; @@ -1126,7 +1140,7 @@ UPDATE taler_withdrawal_operations WHERE withdrawal_uuid=in_withdrawal_uuid; -- Register incoming transaction -CALL register_incoming(reserve_pub_local, tx_row_id); +CALL register_incoming(tx_row_id, 'reserve'::taler_incoming_type, reserve_pub_local, NULL); -- Notify status change PERFORM pg_notify('withdrawal_status', in_withdrawal_uuid::text || ' confirmed'); @@ -1204,7 +1218,7 @@ IF out_balance_insufficient THEN END IF; -- Register incoming transaction -CALL register_incoming(in_reserve_pub, tx_row_id); +CALL register_incoming(tx_row_id, 'reserve'::taler_incoming_type, in_reserve_pub, NULL); -- update stats CALL stats_register_payment('cashin', NULL, converted_amount, in_amount); diff --git a/database-versioning/libeufin-conversion-setup.sql b/database-versioning/libeufin-conversion-setup.sql @@ -50,6 +50,11 @@ LANGUAGE plpgsql AS $$ no_account BOOLEAN; no_config BOOLEAN; BEGIN + -- Only reserve transaction triggers cashin + IF NEW.type != 'reserve' THEN + RETURN NEW; + END IF; + SELECT (amount).val, (amount).frac, wire_transfer_subject, execution_time INTO local_amount.val, local_amount.frac, subject, now_date FROM libeufin_nexus.incoming_transactions diff --git a/database-versioning/libeufin-nexus-0001.sql b/database-versioning/libeufin-nexus-0001.sql @@ -52,7 +52,6 @@ CREATE TABLE incoming_transactions COMMENT ON COLUMN incoming_transactions.bank_id IS 'ISO20022 AccountServicerReference'; --- only active in exchange mode. Note: duplicate keys are another reason to bounce. CREATE TABLE talerable_incoming_transactions (incoming_transaction_id INT8 NOT NULL UNIQUE REFERENCES incoming_transactions(incoming_transaction_id) ON DELETE CASCADE ,reserve_public_key BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_public_key)=32) diff --git a/database-versioning/libeufin-nexus-0006.sql b/database-versioning/libeufin-nexus-0006.sql @@ -0,0 +1,41 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2024 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +BEGIN; + +SELECT _v.register_patch('libeufin-nexus-0006', NULL, NULL); + +SET search_path TO libeufin_nexus; + + +-- Support all taler incoming transaction types +CREATE TYPE taler_incoming_type AS ENUM + ('reserve' ,'kyc', 'wad'); +ALTER TABLE talerable_incoming_transactions + ADD type taler_incoming_type NOT NULL DEFAULT 'reserve', + ADD account_pub BYTEA CHECK (LENGTH(account_pub)=32), + ADD origin_exchange_url TEXT, + ADD wad_id BYTEA CHECK (LENGTH(wad_id)=24), + ALTER COLUMN reserve_public_key DROP NOT NULL, + ADD CONSTRAINT incoming_polymorphism CHECK( + CASE type + WHEN 'reserve' THEN reserve_public_key IS NOT NULL AND account_pub IS NULL AND origin_exchange_url IS NULL AND wad_id IS NULL + WHEN 'kyc' THEN reserve_public_key IS NULL AND account_pub IS NOT NULL AND origin_exchange_url IS NULL AND wad_id IS NULL + WHEN 'wad' THEN reserve_public_key IS NULL AND account_pub IS NULL AND origin_exchange_url IS NOT NULL AND wad_id IS NOT NULL + END + ); +ALTER TABLE talerable_incoming_transactions ALTER COLUMN type DROP DEFAULT; + +COMMIT; diff --git a/database-versioning/libeufin-nexus-drop.sql b/database-versioning/libeufin-nexus-drop.sql @@ -5,7 +5,7 @@ $do$ DECLARE patch text; BEGIN - IF EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name='_v') THEN + IF EXISTS(SELECT FROM information_schema.schemata WHERE schema_name='_v') THEN FOR patch IN SELECT patch_name FROM _v.patches WHERE patch_name LIKE 'libeufin_nexus_%' LOOP PERFORM _v.unregister_patch(patch); END LOOP; diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -220,7 +220,9 @@ CREATE FUNCTION register_incoming_and_talerable( ,IN in_execution_time INT8 ,IN in_debit_payto_uri TEXT ,IN in_bank_id TEXT - ,IN in_reserve_public_key BYTEA + ,IN in_type taler_incoming_type + ,IN in_reserve_pub BYTEA + ,IN in_account_pub BYTEA -- Error status ,OUT out_reserve_pub_reuse BOOLEAN -- Success return @@ -231,26 +233,53 @@ LANGUAGE plpgsql AS $$ DECLARE need_reconcile BOOLEAN; BEGIN --- Check if exists -SELECT incoming_transaction_id, - bank_id IS DISTINCT FROM in_bank_id, - bank_id IS NULL AND amount = in_amount - AND debit_payto_uri = in_debit_payto_uri - AND wire_transfer_subject = in_wire_transfer_subject - INTO out_tx_id, out_reserve_pub_reuse, need_reconcile - FROM talerable_incoming_transactions - JOIN incoming_transactions USING(incoming_transaction_id) - WHERE reserve_public_key = in_reserve_public_key; +IF in_type = 'reserve' THEN + -- Search if already inserted based on unique reserve_pub key + -- Reconcile missing bank_id if metadata match + -- Check for reserve_pub reuse + SELECT incoming_transaction_id, bank_id IS DISTINCT FROM in_bank_id, + bank_id IS NULL AND amount = in_amount + AND debit_payto_uri = in_debit_payto_uri + AND wire_transfer_subject = in_wire_transfer_subject + INTO out_tx_id, out_reserve_pub_reuse, need_reconcile + FROM talerable_incoming_transactions + JOIN incoming_transactions USING(incoming_transaction_id) + WHERE reserve_public_key = in_reserve_pub; -IF FOUND THEN - IF need_reconcile THEN - IF in_bank_id IS NOT NULL THEN - -- Update the bank_id now that we have it + IF FOUND THEN + IF need_reconcile THEN + IF in_bank_id IS NOT NULL THEN + UPDATE incoming_transactions SET bank_id = in_bank_id WHERE incoming_transaction_id = out_tx_id; + END IF; + out_reserve_pub_reuse=false; + END IF; + RETURN; + END IF; +ELSIF in_type = 'kyc' THEN + -- Search if already inserted based on metadata match and account_pub + -- Reconcile missing bank_id + SELECT incoming_transaction_id, bank_id IS NULL + INTO out_tx_id, need_reconcile + FROM talerable_incoming_transactions + JOIN incoming_transactions USING(incoming_transaction_id) + WHERE account_pub = in_account_pub + AND amount = in_amount + AND debit_payto_uri = in_debit_payto_uri + AND wire_transfer_subject = in_wire_transfer_subject; + + IF FOUND THEN + -- If bank_id is missing we assume it's the same transaction + IF in_bank_id IS NULL THEN + RETURN; + -- Else if bank_id is present we assume it's the same transaction and reconciliate + ELSIF need_reconcile THEN UPDATE incoming_transactions SET bank_id = in_bank_id WHERE incoming_transaction_id = out_tx_id; + RETURN; END IF; - out_reserve_pub_reuse=false; + -- Else we consider it's a new transaction END IF; - RETURN; +ELSE + RAISE EXCEPTION 'Unsupported incoming type %', in_type; END IF; -- Register the incoming transaction @@ -259,14 +288,18 @@ SELECT reg.out_found, reg.out_tx_id INTO out_found, out_tx_id; -- Register as talerable -IF NOT EXISTS(SELECT 1 FROM talerable_incoming_transactions WHERE incoming_transaction_id = out_tx_id) THEN +IF NOT EXISTS(SELECT FROM talerable_incoming_transactions WHERE incoming_transaction_id = out_tx_id) THEN -- We cannot use ON CONFLICT here because conversion use a trigger before insertion that isn't idempotent INSERT INTO talerable_incoming_transactions ( incoming_transaction_id + ,type ,reserve_public_key + ,account_pub ) VALUES ( out_tx_id - ,in_reserve_public_key + ,in_type + ,in_reserve_pub + ,in_account_pub ); PERFORM pg_notify('incoming_tx', out_tx_id::text); END IF; diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -86,28 +86,32 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = authApi(cfg.wireGat get("/taler-wire-gateway/history/outgoing") { historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory) } - post("/taler-wire-gateway/admin/add-incoming") { - val req = call.receive<AddIncomingRequest>() - cfg.checkCurrency(req.amount) - req.debit_account.expectRequestIban() + suspend fun ApplicationCall.addIncoming( + amount: TalerAmount, + debitAccount: Payto, + subject: String, + metadata: TalerIncomingMetadata + ) { + cfg.checkCurrency(amount) + debitAccount.expectRequestIban() val timestamp = Instant.now() val bankId = run { val bytes = ByteArray(16).rand() Base32Crockford.encode(bytes) } val res = db.payment.registerTalerableIncoming(IncomingPayment( - amount = req.amount, - debitPaytoUri = req.debit_account.toString(), - wireTransferSubject = "Manual incoming ${req.reserve_pub}", - executionTime = Instant.now(), + amount = amount, + debitPaytoUri = debitAccount.toString(), + wireTransferSubject = subject, + executionTime = timestamp, bankId = bankId - ), req.reserve_pub) + ), metadata) when (res) { IncomingRegistrationResult.ReservePubReuse -> throw conflict( "reserve_pub used already", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) - is IncomingRegistrationResult.Success -> call.respond( + is IncomingRegistrationResult.Success -> respond( AddIncomingResponse( timestamp = TalerProtocolTimestamp(timestamp), row_id = res.id @@ -115,4 +119,22 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = authApi(cfg.wireGat ) } } + post("/taler-wire-gateway/admin/add-incoming") { + val req = call.receive<AddIncomingRequest>() + call.addIncoming( + amount = req.amount, + debitAccount = req.debit_account, + subject = "Manual incoming ${req.reserve_pub}", + metadata = TalerIncomingMetadata(TalerIncomingType.reserve, req.reserve_pub) + ) + } + post("/taler-wire-gateway/admin/add-kycauth") { + val req = call.receive<AddKycauthRequest>() + call.addIncoming( + amount = req.amount, + debitAccount = req.debit_account, + subject = "Manual incoming KYC:${req.account_pub}", + metadata = TalerIncomingMetadata(TalerIncomingType.kyc, req.account_pub) + ) + } } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -96,8 +96,8 @@ suspend fun ingestIncomingPayment( } } runCatching { parseIncomingTxMetadata(payment.wireTransferSubject) }.fold( - onSuccess = { reservePub -> - when (val res = db.payment.registerTalerableIncoming(payment, reservePub)) { + onSuccess = { metadata -> + when (val res = db.payment.registerTalerableIncoming(payment, metadata)) { IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") is IncomingRegistrationResult.Success -> { if (res.new) { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt @@ -20,10 +20,7 @@ package tech.libeufin.nexus.db import tech.libeufin.common.* -import tech.libeufin.common.db.getAmount -import tech.libeufin.common.db.getTalerTimestamp -import tech.libeufin.common.db.one -import tech.libeufin.common.db.poolHistoryGlobal +import tech.libeufin.common.db.* import java.time.Instant /** Data access logic for exchange specific logic */ @@ -31,7 +28,7 @@ class ExchangeDAO(private val db: Database) { /** Query history of taler incoming transactions */ suspend fun incomingHistory( params: HistoryParams - ): List<IncomingReserveTransaction> + ): List<IncomingBankTransaction> = db.poolHistoryGlobal(params, db::listenIncoming, """ SELECT incoming_transaction_id @@ -39,18 +36,31 @@ class ExchangeDAO(private val db: Database) { ,(amount).val AS amount_val ,(amount).frac AS amount_frac ,debit_payto_uri + ,type ,reserve_public_key + ,account_pub FROM talerable_incoming_transactions JOIN incoming_transactions USING(incoming_transaction_id) WHERE """, "incoming_transaction_id") { - IncomingReserveTransaction( - row_id = it.getLong("incoming_transaction_id"), - date = it.getTalerTimestamp("execution_time"), - amount = it.getAmount("amount", db.bankCurrency), - debit_account = it.getString("debit_payto_uri"), - reserve_pub = EddsaPublicKey(it.getBytes("reserve_public_key")), - ) + val type = it.getEnum<TalerIncomingType>("type") + when (type) { + TalerIncomingType.reserve -> IncomingReserveTransaction( + row_id = it.getLong("incoming_transaction_id"), + date = it.getTalerTimestamp("execution_time"), + amount = it.getAmount("amount", db.bankCurrency), + debit_account = it.getString("debit_payto_uri"), + reserve_pub = EddsaPublicKey(it.getBytes("reserve_public_key")), + ) + TalerIncomingType.kyc -> IncomingKycAuthTransaction( + row_id = it.getLong("incoming_transaction_id"), + date = it.getTalerTimestamp("execution_time"), + amount = it.getAmount("amount", db.bankCurrency), + debit_account = it.getString("debit_payto_uri"), + account_pub = EddsaPublicKey(it.getBytes("account_pub")), + ) + TalerIncomingType.wad -> throw UnsupportedOperationException() + } } /** Query exchange history of taler outgoing transactions */ diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -24,6 +24,7 @@ import tech.libeufin.common.db.* import tech.libeufin.nexus.IncomingPayment import tech.libeufin.nexus.OutgoingPayment import java.time.Instant +import java.sql.Types /** Data access logic for incoming & outgoing payments */ class PaymentDAO(private val db: Database) { @@ -110,11 +111,11 @@ class PaymentDAO(private val db: Database) { /** Register an talerable incoming payment */ suspend fun registerTalerableIncoming( paymentData: IncomingPayment, - reservePub: EddsaPublicKey + metadata: TalerIncomingMetadata ): IncomingRegistrationResult = db.serializable( """ SELECT out_reserve_pub_reuse, out_found, out_tx_id - FROM register_incoming_and_talerable((?,?)::taler_amount,?,?,?,?,?) + FROM register_incoming_and_talerable((?,?)::taler_amount,?,?,?,?,?::taler_incoming_type,?,?) """ ) { val executionTime = paymentData.executionTime.micros() @@ -124,7 +125,18 @@ class PaymentDAO(private val db: Database) { setLong(4, executionTime) setString(5, paymentData.debitPaytoUri) setString(6, paymentData.bankId) - setBytes(7, reservePub.raw) + setString(7, metadata.type.name) + when (metadata.type) { + TalerIncomingType.reserve -> { + setBytes(8, metadata.key.raw) + setNull(9, Types.BINARY) + } + TalerIncomingType.kyc -> { + setNull(8, Types.BINARY) + setBytes(9, metadata.key.raw) + } + TalerIncomingType.wad -> throw UnsupportedOperationException() + } one { when { it.getBoolean("out_reserve_pub_reuse") -> IncomingRegistrationResult.ReservePubReuse diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -21,6 +21,7 @@ import org.junit.Test import tech.libeufin.common.ShortHashCode import tech.libeufin.common.TalerAmount import tech.libeufin.common.db.one +import tech.libeufin.common.db.withStatement import tech.libeufin.nexus.AccountType import tech.libeufin.nexus.cli.ingestIncomingPayment import tech.libeufin.nexus.cli.ingestOutgoingPayment @@ -72,19 +73,27 @@ class OutgoingPaymentsTest { } } -suspend fun Database.checkCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) { - conn { - val cIncoming = it.prepareStatement("SELECT count(*) FROM incoming_transactions").one { it.getInt(1) } - val cBounce = it.prepareStatement("SELECT count(*) FROM bounced_transactions").one { it.getInt(1) } - val cTalerable = it.prepareStatement("SELECT count(*) FROM talerable_incoming_transactions").one { it.getInt(1) } - assertEquals(Triple(nbIncoming, nbBounce, nbTalerable), Triple(cIncoming, cBounce, cTalerable)) +suspend fun Database.getCount(): Triple<Int, Int, Int> = serializable( + """ + SELECT (SELECT count(*) FROM incoming_transactions) AS incoming, + (SELECT count(*) FROM bounced_transactions) AS bounce, + (SELECT count(*) FROM talerable_incoming_transactions) AS talerable; + """ +) { + one { + Triple(it.getInt("incoming"), it.getInt("bounce"), it.getInt("talerable")) } } -suspend fun Database.inTxExists(id: String): Boolean = conn { - it.prepareStatement("SELECT EXISTS(SELECT FROM incoming_transactions WHERE bank_id = ?)").apply { - setString(1, id) - }.one { +suspend fun Database.checkCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) { + assertEquals(Triple(nbIncoming, nbBounce, nbTalerable), getCount()) +} + +suspend fun Database.inTxExists(id: String): Boolean = serializable( + "SELECT EXISTS(SELECT FROM incoming_transactions WHERE bank_id = ?)" +) { + setString(1, id) + one { it.getBoolean(1) } } @@ -144,12 +153,13 @@ class IncomingPaymentsTest { } } - // Test creating an incoming taler transaction without and ID and reconcile it later again + // Test creating an incoming reserve taler transaction without and ID and reconcile it later again @Test fun reconcileMissingId() = setup { db, _ -> + val subject = "test with ${ShortHashCode.rand()} reserve pub" + // Register with missing ID - val reserve_pub = ShortHashCode.rand() - val incoming = genInPay("history test with $reserve_pub reserve pub") + val incoming = genInPay(subject) val incomingMissingId = incoming.copy(bankId = null) ingestIncomingPayment(db, incomingMissingId, AccountType.exchange) db.checkCount(1, 0, 1) @@ -160,12 +170,14 @@ class IncomingPaymentsTest { db.checkCount(1, 0, 1) // Different metadata is bounced - ingestIncomingPayment(db, genInPay("another $reserve_pub reserve pub"), AccountType.exchange) - db.checkCount(2, 1, 1) + ingestIncomingPayment(db, genInPay(subject, "KUDOS:9"), AccountType.exchange) + ingestIncomingPayment(db, genInPay("another $subject"), AccountType.exchange) + db.checkCount(3, 2, 1) // Different medata with missing id is ignored ingestIncomingPayment(db, incomingMissingId.copy(amount = TalerAmount("KUDOS:9")), AccountType.exchange) - db.checkCount(2, 1, 1) + ingestIncomingPayment(db, incomingMissingId.copy(wireTransferSubject = "another $subject"), AccountType.exchange) + db.checkCount(3, 2, 1) // Recover bank ID when metadata match ingestIncomingPayment(db, incoming, AccountType.exchange) @@ -173,15 +185,58 @@ class IncomingPaymentsTest { // Idempotent ingestIncomingPayment(db, incoming, AccountType.exchange) - db.checkCount(2, 1, 1) + db.checkCount(3, 2, 1) // Missing ID is ignored ingestIncomingPayment(db, incomingMissingId, AccountType.exchange) - db.checkCount(2, 1, 1) + db.checkCount(3, 2, 1) // Other ID is bounced known that we know the id ingestIncomingPayment(db, incomingMissingId.copy(bankId = "NEW"), AccountType.exchange) - db.checkCount(3, 2, 1) + db.checkCount(4, 3, 1) + } + + // Test creating an incoming kyc taler transaction without and ID and reconcile it later again + @Test + fun reconcileMissingIdKyc() = setup { db, _ -> + val subject = "test with KYC:${ShortHashCode.rand()} account pub" + + // Register with missing ID + val incoming = genInPay(subject) + val incomingMissingId = incoming.copy(bankId = null) + ingestIncomingPayment(db, incomingMissingId, AccountType.exchange) + db.checkCount(1, 0, 1) + assertFalse(db.inTxExists(incoming.bankId!!)) + + // Idempotent + ingestIncomingPayment(db, incomingMissingId, AccountType.exchange) + db.checkCount(1, 0, 1) + + // Different metadata is accepted + ingestIncomingPayment(db, genInPay(subject, "KUDOS:9"), AccountType.exchange) + ingestIncomingPayment(db, genInPay("another $subject"), AccountType.exchange) + db.checkCount(3, 0, 3) + + // Different medata with missing id are accepted + ingestIncomingPayment(db, incomingMissingId.copy(amount = TalerAmount("KUDOS:9.5")), AccountType.exchange) + ingestIncomingPayment(db, incomingMissingId.copy(wireTransferSubject = "again another $subject"), AccountType.exchange) + db.checkCount(5, 0, 5) + + // Recover bank ID when metadata match + ingestIncomingPayment(db, incoming, AccountType.exchange) + assertTrue(db.inTxExists(incoming.bankId!!)) + + // Idempotent + ingestIncomingPayment(db, incoming, AccountType.exchange) + db.checkCount(5, 0, 5) + + // Missing ID is ignored + ingestIncomingPayment(db, incomingMissingId, AccountType.exchange) + db.checkCount(5, 0, 5) + + // Other ID is accepted + ingestIncomingPayment(db, incomingMissingId.copy(bankId = "NEW"), AccountType.exchange) + db.checkCount(6, 0, 6) } } class PaymentInitiationsTest { diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2024 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 @@ -19,9 +19,11 @@ import io.ktor.client.request.* import io.ktor.http.* +import io.ktor.server.testing.* import org.junit.Test import tech.libeufin.common.* import tech.libeufin.nexus.cli.ingestOutgoingPayment +import kotlin.test.* class WireGatewayApiTest { // GET /taler-wire-gateway/config @@ -114,29 +116,24 @@ class WireGatewayApiTest { url = "/taler-wire-gateway/history/incoming", ids = { it.incoming_transactions.map { it.row_id } }, registered = listOf( - { - client.postA("/taler-wire-gateway/admin/add-incoming") { - json { - "amount" to "CHF:12" - "reserve_pub" to EddsaPublicKey.rand() - "debit_account" to grothoffPayto - } - }.assertOk() - }, - { - // Transactions using raw bank transaction logic - talerableIn(db) - } + // Reserve transactions using clean add incoming logic + { addIncoming("CHF:12") }, + + // Reserve transactions using raw bank transaction logic + { talerableIn(db) }, + + // KYC transactions using clean add incoming logic + { addKyc("CHF:12") }, + + // KYC transactions using raw bank transaction logic + { talerableKycIn(db) }, ), ignored = listOf( - { - // Ignore malformed incoming transaction - ingestIn(db) - }, - { - // Ignore outgoing transaction - talerableOut(db) - } + // Ignore malformed incoming transaction + { ingestIn(db) }, + + // Ignore outgoing transaction + { talerableOut(db) }, ) ) } @@ -149,79 +146,115 @@ class WireGatewayApiTest { url = "/taler-wire-gateway/history/outgoing", ids = { it.outgoing_transactions.map { it.row_id } }, registered = listOf( - { - talerableOut(db) - } + // Transfer using raw bank transaction logic + { talerableOut(db) }, ), ignored = listOf( - { - // Ignore pending transfers - transfer() - }, - { - // Ignore manual incoming transaction - talerableIn(db) - }, - { - // Ignore malformed incoming transaction - ingestIn(db) - }, - { - // Ignore malformed outgoing transaction - ingestOutgoingPayment(db, genOutPay("ignored")) - } + // Ignore pending transfers + { transfer() }, + + // Ignore manual incoming transaction + { talerableIn(db) }, + + // Ignore malformed incoming transaction + { ingestIn(db) }, + + // Ignore malformed outgoing transaction + { ingestOutgoingPayment(db, genOutPay("ignored")) }, ) ) } - // POST /taler-wire-gateway/admin/add-incoming - @Test - fun addIncoming() = serverSetup { + suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: TalerIncomingType) { + val (path, key) = when (type) { + TalerIncomingType.reserve -> Pair("add-incoming", "reserve_pub") + TalerIncomingType.kyc -> Pair("add-kycauth", "account_pub") + TalerIncomingType.wad -> throw UnsupportedOperationException() + } val valid_req = obj { "amount" to "CHF:44" - "reserve_pub" to EddsaPublicKey.rand() + key to EddsaPublicKey.rand() "debit_account" to grothoffPayto } - authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/add-incoming") + authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/$path") // Check OK - client.postA("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/$path") { json(valid_req) }.assertOk() - // Trigger conflict due to reused reserve_pub - client.postA("/taler-wire-gateway/admin/add-incoming") { - json(valid_req) - }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) + if (type == TalerIncomingType.reserve) { + // Trigger conflict due to reused reserve_pub + client.postA("/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) + } else if (type == TalerIncomingType.kyc) { + // Non conflict on reuse + client.postA("/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertOk() + } // Currency mismatch - client.postA("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/$path") { json(valid_req) { "amount" to "EUR:33" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) // Bad BASE32 reserve_pub - client.postA("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/$path") { json(valid_req) { - "reserve_pub" to "I love chocolate" + key to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len reserve_pub - client.postA("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/$path") { json(valid_req) { - "reserve_pub" to Base32Crockford.encode(ByteArray(31).rand()) + key to Base32Crockford.encode(ByteArray(31).rand()) } }.assertBadRequest() // Bad payto kind - client.postA("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/$path") { json(valid_req) { "debit_account" to "payto://x-taler-bank/bank.hostname.test/bar" } }.assertBadRequest() } + // POST /taler-wire-gateway/admin/add-incoming + @Test + fun addIncoming() = serverSetup { + talerAddIncomingRoutine(TalerIncomingType.reserve) + } + + // POST /taler-wire-gateway/admin/add-kycauth + @Test + fun addKycAuth() = serverSetup { + talerAddIncomingRoutine(TalerIncomingType.kyc) + } + + @Test + fun addIncomingMix() = serverSetup { db -> + addIncoming("CHF:1") + addKyc("CHF:2") + talerableIn(db, amount = "CHF:3") + talerableKycIn(db, amount = "CHF:4") + client.getA("/taler-wire-gateway/history/incoming?delta=25").assertOkJson<IncomingHistory> { + assertEquals(4, it.incoming_transactions.size) + println(it) + it.incoming_transactions.forEachIndexed { i, tx -> + assertEquals(TalerAmount("CHF:${i+1}"), tx.amount) + if (i % 2 == 1) { + assertIs<IncomingKycAuthTransaction>(tx) + } else { + assertIs<IncomingReserveTransaction>(tx) + } + } + } + } + @Test fun noApi() = serverSetup("mini.conf") { client.get("/taler-wire-gateway/config").assertNotImplemented() diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -67,7 +67,7 @@ const val grothoffPayto = "payto://iban/CH4189144589712575493?receiver-name=Grot val clientKeys = generateNewKeys() -// Gets an HTTP client whose requests are going to be served by 'handler'. +/** Gets an HTTP client whose requests are going to be served by 'handler' */ fun getMockedClient( handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData ): HttpClient = HttpClient(MockEngine) { @@ -79,7 +79,7 @@ fun getMockedClient( } } -// Generates a payment initiation, given its subject. +/** Generates a payment initiation, given its subject */ fun genInitPay( subject: String = "init payment", requestUid: String = "unique" @@ -92,7 +92,7 @@ fun genInitPay( requestUid = requestUid ) -// Generates an incoming payment, given its subject. +/** Generates an incoming payment, given its subject */ fun genInPay(subject: String, amount: String = "KUDOS:44"): IncomingPayment { val bankId = run { val bytes = ByteArray(16).rand() @@ -107,7 +107,7 @@ fun genInPay(subject: String, amount: String = "KUDOS:44"): IncomingPayment { ) } -// Generates an outgoing payment, given its subject and messageId +/** Generates an outgoing payment, given its subject and messageId */ fun genOutPay(subject: String, messageId: String? = null): OutgoingPayment { val id = messageId ?: run { val bytes = ByteArray(16).rand() @@ -135,16 +135,50 @@ suspend fun ApplicationTestBuilder.transfer() { }.assertOk() } +/** Perform a taler incoming transaction of [amount] from merchant to exchange */ +suspend fun ApplicationTestBuilder.addIncoming(amount: String) { + client.postA("/taler-wire-gateway/admin/add-incoming") { + json { + "amount" to TalerAmount(amount) + "reserve_pub" to EddsaPublicKey.rand() + "debit_account" to grothoffPayto + } + }.assertOk() +} + +/** Perform a taler kyc transaction of [amount] from merchant to exchange */ +suspend fun ApplicationTestBuilder.addKyc(amount: String) { + client.postA("/taler-wire-gateway/admin/add-kycauth") { + json { + "amount" to TalerAmount(amount) + "account_pub" to EddsaPublicKey.rand() + "debit_account" to grothoffPayto + } + }.assertOk() +} + /** Ingest a talerable outgoing transaction */ suspend fun talerableOut(db: Database) { val wtid = ShortHashCode.rand() ingestOutgoingPayment(db, genOutPay("$wtid http://exchange.example.com/")) } -/** Ingest a talerable incoming transaction */ -suspend fun talerableIn(db: Database, nullId: Boolean = false) { +/** Ingest a talerable reserve incoming transaction */ +suspend fun talerableIn(db: Database, nullId: Boolean = false, amount: String = "CHF:44") { val reserve_pub = ShortHashCode.rand() - ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve pub").run { + ingestIncomingPayment(db, genInPay("test with $reserve_pub reserve pub", amount).run { + if (nullId) { + copy(bankId = null) + } else { + this + } + }, AccountType.exchange) +} + +/** Ingest a talerable KYC incoming transaction */ +suspend fun talerableKycIn(db: Database, nullId: Boolean = false, amount: String = "CHF:44") { + val account_pub = ShortHashCode.rand() + ingestIncomingPayment(db, genInPay("test with KYC:$account_pub account pub", amount).run { if (nullId) { copy(bankId = null) } else { diff --git a/nexus/src/test/kotlin/routines.kt b/nexus/src/test/kotlin/routines.kt @@ -47,12 +47,6 @@ suspend fun ApplicationTestBuilder.authRoutine( this.method = method headers[HttpHeaders.Authorization] = "Bearer bad-token" }.assertUnauthorized() - - // GLS deployment - // - testing did work ? - // token - basic bearer - // libeufin-nexus - // - wire gateway try camt.052 files } diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -44,7 +44,7 @@ import tech.libeufin.nexus.withDb import java.time.Instant import kotlin.io.path.Path import kotlin.io.path.readText -import kotlin.test.assertEquals +import kotlin.test.* import tech.libeufin.nexus.db.Database as NexusDb fun CliktCommand.run(cmd: String) { @@ -132,12 +132,20 @@ class IntegrationTest { bankCmd.run("dbinit $flags -r") bankCmd.run("passwd admin password $flags") - suspend fun checkCount(db: NexusDb, nbIncoming: Int, nbBounce: Int, nbTalerable: Int) { - db.conn { conn -> - val cIncoming = conn.prepareStatement("SELECT count(*) FROM incoming_transactions").one { it.getInt(1) } - val cBounce = conn.prepareStatement("SELECT count(*) FROM bounced_transactions").one { it.getInt(1) } - val cTalerable = conn.prepareStatement("SELECT count(*) FROM talerable_incoming_transactions").one { it.getInt(1) } - assertEquals(Triple(nbIncoming, nbBounce, nbTalerable), Triple(cIncoming, cBounce, cTalerable)) + suspend fun NexusDb.checkCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) { + serializable( + """ + SELECT (SELECT count(*) FROM incoming_transactions) AS incoming, + (SELECT count(*) FROM bounced_transactions) AS bounce, + (SELECT count(*) FROM talerable_incoming_transactions) AS talerable; + """ + ) { + one { + assertEquals( + Triple(nbIncoming, nbBounce, nbTalerable), + Triple(it.getInt("incoming"), it.getInt("bounce"), it.getInt("talerable")) + ) + } } } @@ -153,23 +161,33 @@ class IntegrationTest { } val reservePub = EddsaPublicKey.rand() - val payment = IncomingPayment( + val reservePayment = IncomingPayment( amount = TalerAmount("EUR:10"), debitPaytoUri = userPayTo.toString(), wireTransferSubject = "Error test $reservePub", executionTime = Instant.now(), - bankId = "error" + bankId = "reserve_error" ) assertException("ERROR: cashin failed: missing exchange account") { - ingestIncomingPayment(db, payment, AccountType.exchange) + ingestIncomingPayment(db, reservePayment, AccountType.exchange) } + // But KYC works + ingestIncomingPayment( + db, + reservePayment.copy( + bankId = "kyc", + wireTransferSubject = "Error test KYC:${EddsaPublicKey.rand()}" + ), + AccountType.exchange + ) + // Create exchange account bankCmd.run("create-account $flags -u exchange -p password --name 'Mr Money' --exchange") assertException("ERROR: cashin currency conversion failed: missing conversion rates") { - ingestIncomingPayment(db, payment, AccountType.exchange) + ingestIncomingPayment(db, reservePayment, AccountType.exchange) } // Start server @@ -195,40 +213,39 @@ class IntegrationTest { }.assertNoContent() assertException("ERROR: cashin failed: admin balance insufficient") { - db.payment.registerTalerableIncoming(payment, reservePub) + db.payment.registerTalerableIncoming(reservePayment, TalerIncomingMetadata(TalerIncomingType.reserve, reservePub)) } // Allow admin debt bankCmd.run("edit-account admin --debit_threshold KUDOS:100 $flags") // Too small amount - checkCount(db, 0, 0, 0) - ingestIncomingPayment(db, payment.copy( + db.checkCount(1, 0, 1) + ingestIncomingPayment(db, reservePayment.copy( amount = TalerAmount("EUR:0.01"), ), AccountType.exchange) - checkCount(db, 1, 1, 0) + db.checkCount(2, 1, 1) client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { basicAuth("exchange", "password") }.assertNoContent() // Check success - val valid_payment = IncomingPayment( - amount = TalerAmount("EUR:10"), - debitPaytoUri = userPayTo.toString(), - wireTransferSubject = "Success ${Base32Crockford32B.rand().encoded()}", - executionTime = Instant.now(), + val validPayment = reservePayment.copy( + wireTransferSubject = "Success $reservePub", bankId = "success" ) - ingestIncomingPayment(db, valid_payment, AccountType.exchange) - checkCount(db, 2, 1, 1) + ingestIncomingPayment(db, validPayment, AccountType.exchange) + db.checkCount(3, 1, 2) client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { basicAuth("exchange", "password") }.assertOkJson<BankAccountTransactionsResponse>() // Check idempotency - ingestIncomingPayment(db, valid_payment, AccountType.exchange) - checkCount(db, 2, 1, 1) - // TODO check double insert cashin with different subject + ingestIncomingPayment(db, validPayment, AccountType.exchange) + ingestIncomingPayment(db, validPayment.copy( + wireTransferSubject="Success 2 $reservePub" + ), AccountType.exchange) + db.checkCount(3, 1, 2) } } @@ -304,6 +321,7 @@ class IntegrationTest { }.assertOkJson<IncomingHistory> { val tx = it.incoming_transactions.first() assertEquals(converted, tx.amount) + assertIs<IncomingReserveTransaction>(tx) assertEquals(reservePub, tx.reserve_pub) } } diff --git a/testbench/src/test/kotlin/MigrationTest.kt b/testbench/src/test/kotlin/MigrationTest.kt @@ -91,5 +91,8 @@ class MigrationTest { // libeufin-nexus-0005 conn.execSQLUpdate(Path("../database-versioning/libeufin-nexus-0005.sql").readText()) + + // libeufin-nexus-0006 + conn.execSQLUpdate(Path("../database-versioning/libeufin-nexus-0006.sql").readText()) } } \ No newline at end of file