libeufin

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

commit 7ffa7a9f21f20f854b15c44c81b9db9eac93623e
parent cc35923acc45c1fe0acf4fa277ec490b43a27094
Author: Antoine A <>
Date:   Tue, 14 Nov 2023 17:12:17 +0000

Track used iban and improve iban verification

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt | 1-
Mbank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 17++++++++++++++++-
Mbank/src/test/kotlin/AmountTest.kt | 6+++---
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 7+++----
Mbank/src/test/kotlin/CoreBankApiTest.kt | 35+++++++++++++++--------------------
Mbank/src/test/kotlin/SecurityTest.kt | 6+++---
Mbank/src/test/kotlin/StatsTest.kt | 2+-
Mbank/src/test/kotlin/WireGatewayApiTest.kt | 46+++++++++++++++++-----------------------------
Mbank/src/test/kotlin/helpers.kt | 24+++++++++++++++++-------
Mdatabase-versioning/libeufin-bank-0001.sql | 20++++++++++++--------
11 files changed, 141 insertions(+), 104 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -29,6 +29,7 @@ import java.time.Instant import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit import java.util.* +import java.math.BigInteger import java.net.* import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.* @@ -43,9 +44,7 @@ import org.slf4j.event.Level private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.TalerCommon") const val MAX_SAFE_INTEGER = 9007199254740991L; // 2^53 - 1 -/** - * 32-byte Crockford's Base32 encoded data. - */ +/** 32-byte Crockford's Base32 encoded data */ @Serializable(with = Base32Crockford32B.Serializer::class) class Base32Crockford32B { private var encoded: String? = null @@ -98,9 +97,7 @@ class Base32Crockford32B { } } -/** - * 64-byte Crockford's Base32 encoded data. - */ +/** 64-byte Crockford's Base32 encoded data */ @Serializable(with = Base32Crockford64B.Serializer::class) class Base32Crockford64B { private var encoded: String? = null @@ -153,9 +150,9 @@ class Base32Crockford64B { } } -/** 32-byte hash code. */ +/** 32-byte hash code */ typealias ShortHashCode = Base32Crockford32B; -/** 64-byte hash code. */ +/** 64-byte hash code */ typealias HashCode = Base32Crockford64B; /** * EdDSA and ECDHE public keys always point on Curve25519 @@ -164,9 +161,7 @@ typealias HashCode = Base32Crockford64B; */ typealias EddsaPublicKey = Base32Crockford32B; -/** - * Timestamp containing the number of seconds since epoch. - */ +/** Timestamp containing the number of seconds since epoch */ @Serializable data class TalerProtocolTimestamp( @Serializable(with = TalerProtocolTimestamp.Serializer::class) @@ -234,18 +229,31 @@ class TalerAmount { this.currency = currency } constructor(encoded: String) { - fun badAmount(hint: String): Exception = - badRequest(hint, TalerErrorCode.BANK_BAD_FORMAT_AMOUNT) - - val match = PATTERN.matchEntire(encoded) ?: throw badAmount("Invalid amount format"); + val match = PATTERN.matchEntire(encoded) ?: throw badRequest( + "Invalid amount format", + TalerErrorCode.BANK_BAD_FORMAT_AMOUNT + ); val (currency, value, frac) = match.destructured this.currency = currency - this.value = value.toLongOrNull() ?: throw badAmount("Invalid value") - if (this.value > MAX_VALUE) throw badAmount("Value specified in amount is too large") + this.value = value.toLongOrNull() ?: throw badRequest( + "Invalid value", + TalerErrorCode.BANK_BAD_FORMAT_AMOUNT + ) + if (this.value > MAX_VALUE) throw badRequest( + "Value specified in amount is too large", + TalerErrorCode.BANK_NUMBER_TOO_BIG + ) this.frac = if (frac.isEmpty()) { 0 } else { - var tmp = frac.toIntOrNull() ?: throw badAmount("Invalid fractional value") + var tmp = frac.toIntOrNull() ?: throw badRequest( + "Invalid fractional value", + TalerErrorCode.BANK_BAD_FORMAT_AMOUNT + ) + if (tmp > FRACTION_BASE) throw badRequest( + "Fractional calue specified in amount is too large", + TalerErrorCode.BANK_NUMBER_TOO_BIG + ) repeat(8 - frac.length) { tmp *= 10 } @@ -295,17 +303,14 @@ class DecimalNumber { val frac: Int constructor(encoded: String) { - fun badAmount(hint: String): Exception = - badRequest(hint, TalerErrorCode.BANK_BAD_FORMAT_AMOUNT) - - val match = PATTERN.matchEntire(encoded) ?: throw badAmount("Invalid decimal number format"); + val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format"); val (value, frac) = match.destructured - this.value = value.toLongOrNull() ?: throw badAmount("Invalid value") - if (this.value > TalerAmount.MAX_VALUE) throw badAmount("Value specified in decimal number is too large") + this.value = value.toLongOrNull() ?: throw badRequest("Invalid value") + if (this.value > TalerAmount.MAX_VALUE) throw badRequest("Value specified in decimal number is too large") this.frac = if (frac.isEmpty()) { 0 } else { - var tmp = frac.toIntOrNull() ?: throw badAmount("Invalid fractional value") + var tmp = frac.toIntOrNull() ?: throw badRequest("Invalid fractional value") repeat(8 - frac.length) { tmp *= 10 } @@ -422,6 +427,7 @@ sealed class PaytoUri { class IbanPayTo: PaytoUri { val parsed: URI val canonical: String + val iban: String override val amount: TalerAmount? override val message: String? override val receiverName: String? @@ -433,8 +439,9 @@ class IbanPayTo: PaytoUri { val splitPath = parsed.path.split("/").filter { it.isNotEmpty() } require(splitPath.size < 3 && splitPath.isNotEmpty()) { "too many path segments" } - val iban = (if (splitPath.size == 1) splitPath[0] else splitPath[1]).replace("-", "").uppercase() - // TODO normalize && check IBAN ? + val rawIban = if (splitPath.size == 1) splitPath[0] else splitPath[1] + iban = rawIban.uppercase().replace(SEPARATOR, "") + checkIban(iban) canonical = "payto://iban/$iban" val params = (parsed.query ?: "").parseUrlEncodedParameters(); @@ -443,6 +450,8 @@ class IbanPayTo: PaytoUri { receiverName = params["receiver-name"] } + override fun toString(): String = canonical + internal object Serializer : KSerializer<IbanPayTo> { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("IbanPayTo", PrimitiveKind.STRING) @@ -455,4 +464,22 @@ class IbanPayTo: PaytoUri { return IbanPayTo(decoder.decodeString()) } } + + companion object { + private val SEPARATOR = Regex("[\\ \\-]"); + + fun checkIban(iban: String) { + val builder = StringBuilder(iban.length + iban.asSequence().map { if (it.isDigit()) 1 else 2 }.sum()) + (iban.subSequence(4, iban.length).asSequence() + iban.subSequence(0, 4).asSequence()).forEach { + if (it.isDigit()) { + builder.append(it) + } else { + builder.append((it.code - 'A'.code) + 10) + } + } + val str = builder.toString() + val mod = str.toBigInteger().mod(97.toBigInteger()).toInt(); + if (mod != 1) throw badRequest("Iban malformed, modulo is $mod expected 1") + } + } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt @@ -31,7 +31,6 @@ import net.taler.common.errorcodes.TalerErrorCode import org.slf4j.Logger import org.slf4j.LoggerFactory import tech.libeufin.util.extractReservePubFromSubject -import tech.libeufin.util.stripIbanPayto import java.time.Instant import kotlin.math.abs diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -217,6 +217,7 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val maxDebt: TalerAmount, bonus: TalerAmount? ): CustomerCreationResult = conn { it -> + val now = Instant.now().toDbMicros() ?: throw faultyTimestampByBank(); it.transaction { conn -> val idempotent = conn.prepareStatement(""" SELECT password_hash, name=? @@ -270,6 +271,20 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val setString(6, cashoutPayto?.canonical) oneOrNull { it.getLong("customer_id") }!! } + + conn.prepareStatement(""" + INSERT INTO iban_history( + iban + ,creation_time + ) VALUES (?, ?) + """).run { + setString(1, internalPaytoUri.iban) + setLong(2, now) + if (!executeUpdateViolation()) { + conn.rollback() + return@transaction CustomerCreationResult.CONFLICT_PAY_TO + } + } conn.prepareStatement(""" INSERT INTO bank_accounts( @@ -300,7 +315,7 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val setString(1, internalPaytoUri.canonical) setLong(2, bonus.value) setInt(3, bonus.frac) - setLong(4, Instant.now().toDbMicros() ?: throw faultyTimestampByBank()) + setLong(4, now) executeQuery().use { when { !it.next() -> throw internalServerError("Bank transaction didn't properly return") diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -31,13 +31,13 @@ class AmountTest { @Test fun computationTest() = bankSetup { db -> val conn = db.dbPool.getConnection().unwrap(PgConnection::class.java) - conn.execSQLUpdate("UPDATE libeufin_bank.bank_accounts SET balance.val = 100000 WHERE internal_payto_uri = '${IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ").canonical}'") + conn.execSQLUpdate("UPDATE libeufin_bank.bank_accounts SET balance.val = 100000 WHERE internal_payto_uri = '$exchangePayto'") val stmt = conn.prepareStatement(""" UPDATE libeufin_bank.bank_accounts SET balance = (?, ?)::taler_amount ,has_debt = ? ,max_debt = (?, ?)::taler_amount - WHERE internal_payto_uri = '${IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ").canonical}' + WHERE internal_payto_uri = '$merchantPayto' """) suspend fun routine(balance: TalerAmount, due: TalerAmount, hasBalanceDebt: Boolean, maxDebt: TalerAmount): Boolean { stmt.setLong(1, balance.value) @@ -49,7 +49,7 @@ class AmountTest { // Check bank transaction stmt.executeUpdate() val (txRes, _) = db.bankTransaction( - creditAccountPayto = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"), + creditAccountPayto = exchangePayto, debitAccountUsername = "merchant", subject = "test", amount = due, diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -11,7 +11,6 @@ import net.taler.common.errorcodes.TalerErrorCode import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.stripIbanPayto import java.util.* import java.time.Instant import kotlin.test.* @@ -53,7 +52,7 @@ class BankIntegrationApiTest { val reserve_pub = randEddsaPublicKey() val req = json { "reserve_pub" to reserve_pub - "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + "selected_exchange" to exchangePayto } // Check bad UUID @@ -104,14 +103,14 @@ class BankIntegrationApiTest { client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody { "reserve_pub" to randEddsaPublicKey() - "selected_exchange" to IbanPayTo("payto://iban/UNKNOWN-IBAN-XYZ") + "selected_exchange" to unknownPayto } }.assertConflict(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) // Check account not exchange client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody { "reserve_pub" to randEddsaPublicKey() - "selected_exchange" to IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ") + "selected_exchange" to merchantPayto } }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE) } diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -634,7 +634,7 @@ class CoreBankTransactionsApiTest { authRoutine("/accounts/merchant/transactions/1", method = HttpMethod.Get) // Create transaction - tx("merchant", "KUDOS:0.3", "exchange") + tx("merchant", "KUDOS:0.3", "exchange", "tx") // Check OK client.get("/accounts/merchant/transactions/1") { basicAuth("merchant", "merchant-password") @@ -657,7 +657,7 @@ class CoreBankTransactionsApiTest { @Test fun create() = bankSetup { _ -> val valid_req = json { - "payto_uri" to "payto://iban/EXCHANGE-IBAN-XYZ?message=payout" + "payto_uri" to "$exchangePayto?message=payout" "amount" to "KUDOS:0.3" } @@ -682,7 +682,7 @@ class CoreBankTransactionsApiTest { client.post("/accounts/merchant/transactions") { basicAuth("merchant", "merchant-password") jsonBody { - "payto_uri" to "payto://iban/EXCHANGE-IBAN-XYZ?message=payout2&amount=KUDOS:1.05" + "payto_uri" to "$exchangePayto?message=payout2&amount=KUDOS:1.05" } }.assertOk().run { val id = json<TransactionCreateResponse>().row_id @@ -699,7 +699,7 @@ class CoreBankTransactionsApiTest { client.post("/accounts/merchant/transactions") { basicAuth("merchant", "merchant-password") jsonBody { - "payto_uri" to "payto://iban/EXCHANGE-IBAN-XYZ?message=payout3&amount=KUDOS:1.05" + "payto_uri" to "$exchangePayto?message=payout3&amount=KUDOS:1.05" "amount" to "KUDOS:10.003" } }.assertOk().run { @@ -732,7 +732,7 @@ class CoreBankTransactionsApiTest { basicAuth("merchant", "merchant-password") contentType(ContentType.Application.Json) jsonBody(valid_req) { - "payto_uri" to "payto://iban/EXCHANGE-IBAN-XYZ" + "payto_uri" to "$exchangePayto" } }.assertBadRequest() // Unknown creditor @@ -740,7 +740,7 @@ class CoreBankTransactionsApiTest { basicAuth("merchant", "merchant-password") contentType(ContentType.Application.Json) jsonBody(valid_req) { - "payto_uri" to "payto://iban/UNKNOWN-IBAN-XYZ?message=payout" + "payto_uri" to "$unknownPayto?message=payout" } }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR) // Transaction to self @@ -748,7 +748,7 @@ class CoreBankTransactionsApiTest { basicAuth("merchant", "merchant-password") contentType(ContentType.Application.Json) jsonBody(valid_req) { - "payto_uri" to "payto://iban/MERCHANT-IBAN-XYZ?message=payout" + "payto_uri" to "$merchantPayto?message=payout" } }.assertConflict(TalerErrorCode.BANK_SAME_ACCOUNT) @@ -782,17 +782,12 @@ class CoreBankTransactionsApiTest { checkBalance(true, "KUDOS:2.4", false, "KUDOS:0") // Send 2 times 3 repeat(2) { - client.post("/accounts/merchant/transactions") { - basicAuth("merchant", "merchant-password") - jsonBody { - "payto_uri" to "payto://iban/CUSTOMER-IBAN-XYZ?message=payout2&amount=KUDOS:3" - } - }.assertOk() + tx("merchant", "KUDOS:3", "customer") } client.post("/accounts/merchant/transactions") { basicAuth("merchant", "merchant-password") jsonBody { - "payto_uri" to "payto://iban/CUSTOMER-IBAN-XYZ?message=payout2&amount=KUDOS:3" + "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:3" } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) checkBalance(true, "KUDOS:8.4", false, "KUDOS:6") @@ -800,7 +795,7 @@ class CoreBankTransactionsApiTest { client.post("/accounts/customer/transactions") { basicAuth("customer", "customer-password") jsonBody { - "payto_uri" to "payto://iban/MERCHANT-IBAN-XYZ?message=payout2&amount=KUDOS:10" + "payto_uri" to "$merchantPayto?message=payout2&amount=KUDOS:10" } }.assertOk() checkBalance(false, "KUDOS:1.6", true, "KUDOS:4") @@ -879,7 +874,7 @@ class CoreBankWithdrawalApiTest { client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody { "reserve_pub" to randEddsaPublicKey() - "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + "selected_exchange" to exchangePayto } }.assertOk() @@ -899,7 +894,7 @@ class CoreBankWithdrawalApiTest { client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody { "reserve_pub" to randEddsaPublicKey() - "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + "selected_exchange" to exchangePayto } }.assertOk() client.post("/withdrawals/$uuid/confirm").assertNoContent() @@ -943,7 +938,7 @@ class CoreBankWithdrawalApiTest { client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody { "reserve_pub" to randEddsaPublicKey() - "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + "selected_exchange" to exchangePayto } }.assertOk() @@ -963,7 +958,7 @@ class CoreBankWithdrawalApiTest { client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody { "reserve_pub" to randEddsaPublicKey() - "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + "selected_exchange" to exchangePayto } }.assertOk() client.post("/withdrawals/$uuid/abort").assertNoContent() @@ -983,7 +978,7 @@ class CoreBankWithdrawalApiTest { client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody { "reserve_pub" to randEddsaPublicKey() - "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + "selected_exchange" to exchangePayto } }.assertOk() diff --git a/bank/src/test/kotlin/SecurityTest.kt b/bank/src/test/kotlin/SecurityTest.kt @@ -35,7 +35,7 @@ class SecurityTest { @Test fun bodySizeLimit() = bankSetup { _ -> val valid_req = json { - "payto_uri" to "payto://iban/EXCHANGE-IBAN-XYZ?message=payout" + "payto_uri" to "$exchangePayto?message=payout" "amount" to "KUDOS:0.3" } client.post("/accounts/merchant/transactions") { @@ -47,7 +47,7 @@ class SecurityTest { client.post("/accounts/merchant/transactions") { basicAuth("merchant", "merchant-password") jsonBody(valid_req) { - "payto_uri" to "payto://iban/EXCHANGE-IBAN-XYZ?message=payout${"A".repeat(4100)}" + "payto_uri" to "$exchangePayto?message=payout${"A".repeat(4100)}" } }.assertBadRequest() @@ -55,7 +55,7 @@ class SecurityTest { client.post("/accounts/merchant/transactions") { basicAuth("merchant", "merchant-password") jsonBody(valid_req, deflate = true) { - "payto_uri" to "payto://iban/EXCHANGE-IBAN-XYZ?message=payout${"A".repeat(4100)}" + "payto_uri" to "$exchangePayto?message=payout${"A".repeat(4100)}" } }.assertBadRequest() } diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -53,7 +53,7 @@ class StatsTest { db.conn { conn -> val stmt = conn.prepareStatement("SELECT 0 FROM cashin(?, ?, (?, ?)::taler_amount, ?)") stmt.setLong(1, Instant.now().toDbMicros()!!) - stmt.setString(2, IbanPayTo("payto://iban/CUSTOMER-IBAN-XYZ").canonical) + stmt.setString(2, customerPayto.canonical) val amount = TalerAmount(amount) stmt.setLong(3, amount.value) stmt.setInt(4, amount.frac) diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -17,18 +17,6 @@ import kotlin.test.assertNotNull import randHashCode class WireGatewayApiTest { - suspend fun Database.genTransaction(from: String, to: IbanPayTo, subject: String) { - bankTransaction( - creditAccountPayto = to, - debitAccountUsername = from, - subject = subject, - amount = TalerAmount("KUDOS:10"), - timestamp = Instant.now(), - ).run { - assertEquals(BankTransactionResult.SUCCESS, first) - } - } - // Test endpoint is correctly authenticated suspend fun ApplicationTestBuilder.authRoutine(path: String, body: JsonObject? = null, method: HttpMethod = HttpMethod.Post, requireAdmin: Boolean = false) { // No body when authentication must happen before parsing the body @@ -78,7 +66,7 @@ class WireGatewayApiTest { "amount" to "KUDOS:55" "exchange_base_url" to "http://exchange.example.com/" "wtid" to randShortHashCode() - "credit_account" to "payto://iban/MERCHANT-IBAN-XYZ" + "credit_account" to merchantPayto }; authRoutine("/accounts/merchant/taler-wire-gateway/transfer", valid_req) @@ -125,7 +113,7 @@ class WireGatewayApiTest { jsonBody(valid_req) { "request_uid" to randHashCode() "wtid" to randShortHashCode() - "credit_account" to "payto://iban/UNKNOWN-IBAN-XYZ" + "credit_account" to unknownPayto } }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR) @@ -135,7 +123,7 @@ class WireGatewayApiTest { jsonBody(valid_req) { "request_uid" to randHashCode() "wtid" to randShortHashCode() - "credit_account" to "payto://iban/EXCHANGE-IBAN-XYZ" + "credit_account" to exchangePayto } }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) @@ -176,7 +164,7 @@ class WireGatewayApiTest { * Testing the /history/incoming call from the TWG API. */ @Test - fun historyIncoming() = bankSetup { db -> + fun historyIncoming() = bankSetup { // Give Foo reasonable debt allowance: setMaxDebt("merchant", TalerAmount("KUDOS:1000")) @@ -198,11 +186,11 @@ class WireGatewayApiTest { addIncoming("KUDOS:10") } // Should not show up in the taler wire gateway API history - db.genTransaction("merchant", IbanPayTo("payto://iban/exchange-IBAN-XYZ"), "bogus") + tx("merchant", "KUDOS:10", "exchange", "bogus") // Exchange pays merchant once, but that should not appear in the result - db.genTransaction("exchange", IbanPayTo("payto://iban/merchant-IBAN-XYZ"), "ignored") + tx("exchange", "KUDOS:10", "merchant", "ignored") // Gen one transaction using raw bank transaction logic - db.genTransaction("merchant", IbanPayTo("payto://iban/exchange-IBAN-XYZ"), IncomingTxMetadata(randShortHashCode()).encode()) + tx("merchant", "KUDOS:10", "exchange", IncomingTxMetadata(randShortHashCode()).encode()) // Gen one transaction using withdraw logic client.post("/accounts/merchant/withdrawals") { basicAuth("merchant", "merchant-password") @@ -213,7 +201,7 @@ class WireGatewayApiTest { client.post("/taler-integration/withdrawal-operation/${uuid}") { jsonBody { "reserve_pub" to randEddsaPublicKey() - "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + "selected_exchange" to exchangePayto } }.assertOk() client.post("/withdrawals/${uuid}/confirm") { @@ -274,7 +262,7 @@ class WireGatewayApiTest { } } delay(200) - db.genTransaction("merchant", IbanPayTo("payto://iban/exchange-IBAN-XYZ"), IncomingTxMetadata(randShortHashCode()).encode()) + tx("merchant", "KUDOS:10", "exchange", IncomingTxMetadata(randShortHashCode()).encode()) } // Test trigger by withdraw operationr @@ -296,7 +284,7 @@ class WireGatewayApiTest { client.post("/taler-integration/withdrawal-operation/${uuid}") { jsonBody { "reserve_pub" to randEddsaPublicKey() - "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + "selected_exchange" to exchangePayto } }.assertOk() client.post("/withdrawals/${uuid}/confirm") { @@ -326,7 +314,7 @@ class WireGatewayApiTest { * Testing the /history/outgoing call from the TWG API. */ @Test - fun historyOutgoing() = bankSetup { db -> + fun historyOutgoing() = bankSetup { setMaxDebt("exchange", TalerAmount("KUDOS:1000000")) suspend fun HttpResponse.assertHistory(size: Int) { @@ -347,12 +335,12 @@ class WireGatewayApiTest { transfer("KUDOS:10") } // Should not show up in the taler wire gateway API history - db.genTransaction("exchange", IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ"), "bogus") + tx("exchange", "KUDOS:10", "merchant", "bogus") // Merchant pays exchange once, but that should not appear in the result - db.genTransaction("merchant", IbanPayTo("payto://iban/exchange-IBAN-XYZ"), "ignored") + tx("merchant", "KUDOS:10", "exchange", "ignored") // Gen two transactions using raw bank transaction logic repeat(2) { - db.genTransaction("exchange", IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ"), OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode()) + tx("exchange", "KUDOS:10", "merchant", OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode()) } // Check ignore bogus subject @@ -420,7 +408,7 @@ class WireGatewayApiTest { val valid_req = json { "amount" to "KUDOS:44" "reserve_pub" to randEddsaPublicKey() - "debit_account" to "payto://iban/MERCHANT-IBAN-XYZ" + "debit_account" to merchantPayto }; authRoutine("/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req, requireAdmin = true) @@ -455,7 +443,7 @@ class WireGatewayApiTest { basicAuth("admin", "admin-password") jsonBody(valid_req) { "reserve_pub" to randEddsaPublicKey() - "debit_account" to "payto://iban/UNKNOWN-IBAN-XYZ" + "debit_account" to unknownPayto } }.assertConflict(TalerErrorCode.BANK_UNKNOWN_DEBTOR) @@ -464,7 +452,7 @@ class WireGatewayApiTest { basicAuth("admin", "admin-password") jsonBody(valid_req) { "reserve_pub" to randEddsaPublicKey() - "debit_account" to "payto://iban/EXCHANGE-IBAN-XYZ" + "debit_account" to exchangePayto } }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -16,6 +16,16 @@ import tech.libeufin.util.* /* ----- Setup ----- */ +val merchantPayto = IbanPayTo(genIbanPaytoUri()) +val exchangePayto = IbanPayTo(genIbanPaytoUri()) +val customerPayto = IbanPayTo(genIbanPaytoUri()) +val unknownPayto = IbanPayTo(genIbanPaytoUri()) +val paytos = mapOf( + "merchant" to merchantPayto, + "exchange" to exchangePayto, + "customer" to customerPayto +) + fun setup( conf: String = "test.conf", lambda: suspend (Database, BankConfig) -> Unit @@ -43,7 +53,7 @@ fun bankSetup( login = "merchant", password = "merchant-password", name = "Merchant", - internalPaytoUri = IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ"), + internalPaytoUri = merchantPayto, maxDebt = TalerAmount(10, 0, "KUDOS"), isTalerExchange = false, isPublic = false, @@ -53,7 +63,7 @@ fun bankSetup( login = "exchange", password = "exchange-password", name = "Exchange", - internalPaytoUri = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"), + internalPaytoUri = exchangePayto, maxDebt = TalerAmount(10, 0, "KUDOS"), isTalerExchange = true, isPublic = false, @@ -63,7 +73,7 @@ fun bankSetup( login = "customer", password = "customer-password", name = "Customer", - internalPaytoUri = IbanPayTo("payto://iban/CUSTOMER-IBAN-XYZ"), + internalPaytoUri = customerPayto, maxDebt = TalerAmount(10, 0, "KUDOS"), isTalerExchange = false, isPublic = false, @@ -103,11 +113,11 @@ suspend fun ApplicationTestBuilder.assertBalance(account: String, info: CreditDe } } -suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String): Long { +suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, subject: String = "payout"): Long { return client.post("/accounts/$from/transactions") { basicAuth("$from", "$from-password") jsonBody { - "payto_uri" to "payto://iban/$to-IBAN-XYZ?message=tx&amount=$amount" + "payto_uri" to "${paytos[to]}?message=${subject.encodeURLQueryComponent()}&amount=$amount" } }.assertOk().run { json<TransactionCreateResponse>().row_id @@ -122,7 +132,7 @@ suspend fun ApplicationTestBuilder.transfer(amount: String) { "amount" to TalerAmount(amount) "exchange_base_url" to "http://exchange.example.com/" "wtid" to randShortHashCode() - "credit_account" to "payto://iban/MERCHANT-IBAN-XYZ" + "credit_account" to merchantPayto } }.assertOk() } @@ -133,7 +143,7 @@ suspend fun ApplicationTestBuilder.addIncoming(amount: String) { jsonBody { "amount" to TalerAmount(amount) "reserve_pub" to randEddsaPublicKey() - "debit_account" to "payto://iban/MERCHANT-IBAN-XYZ" + "debit_account" to merchantPayto } }.assertOk() } diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -67,7 +67,6 @@ CREATE TABLE IF NOT EXISTS customers ,phone TEXT ,cashout_payto TEXT ); - COMMENT ON COLUMN customers.cashout_payto IS 'RFC 8905 payto URI to collect fiat payments that come from the conversion of regional currency cash-out operations.'; COMMENT ON COLUMN customers.name @@ -82,11 +81,9 @@ CREATE TABLE IF NOT EXISTS bearer_tokens ,is_refreshable BOOLEAN ,bank_customer BIGINT NOT NULL REFERENCES customers(customer_id) ON DELETE CASCADE ); - COMMENT ON TABLE bearer_tokens IS 'Login tokens associated with one bank customer. There is currently' ' no garbage collector that deletes the expired tokens from the table'; - COMMENT ON COLUMN bearer_tokens.bank_customer IS 'The customer that directly created this token, or the customer that' ' created the very first token that originated all the refreshes until' @@ -104,7 +101,6 @@ CREATE TABLE IF NOT EXISTS bank_accounts ,max_debt taler_amount DEFAULT (0, 0) ,has_debt BOOLEAN NOT NULL DEFAULT FALSE ); - COMMENT ON TABLE bank_accounts IS 'In Sandbox, usernames (AKA logins) are different entities respect to bank accounts (in contrast to what the Python bank @@ -114,14 +110,18 @@ one bank account for one user, and additionally the bank account label matches always the login.'; COMMENT ON COLUMN bank_accounts.has_debt IS 'When true, the balance is negative'; - COMMENT ON COLUMN bank_accounts.is_public IS 'Indicates whether the bank account history can be publicly shared'; - COMMENT ON COLUMN bank_accounts.owning_customer_id IS 'Login that owns the bank account'; +CREATE TABLE IF NOT EXISTS iban_history + (iban TEXT PRIMARY key + ,creation_time INT8 NOT NULL + ); +COMMENT ON TABLE iban_history IS 'Track all generated iban, some might be unused.'; + -- end of: bank accounts -- start of: money transactions @@ -185,7 +185,7 @@ COMMENT ON COLUMN challenges.confirmation_date CREATE TABLE IF NOT EXISTS cashout_operations (cashout_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE - ,request_uid BYTEA NOT NULL UNIQUE CHECK (LENGTH(request_uid)=32) + ,request_uid BYTEA NOT NULL PRIMARY KEY CHECK (LENGTH(request_uid)=32) ,amount_debit taler_amount NOT NULL ,amount_credit taler_amount NOT NULL ,subject TEXT NOT NULL @@ -204,6 +204,9 @@ CREATE TABLE IF NOT EXISTS cashout_operations ON DELETE RESTRICT ON UPDATE RESTRICT ); +COMMENT ON COLUMN cashout_operations.bank_account IS 'Bank amount to debit during confirmation'; +COMMENT ON COLUMN cashout_operations.challenge IS 'TAN challenge used to confirm the operation'; +COMMENT ON COLUMN cashout_operations.local_transaction IS 'Transaction generated during confirmation'; -- end of: cashout management @@ -229,7 +232,8 @@ CREATE TABLE IF NOT EXISTS taler_exchange_incoming ); CREATE TABLE IF NOT EXISTS taler_withdrawal_operations - (withdrawal_uuid uuid NOT NULL PRIMARY KEY + (withdrawal_id BIGINT GENERATED BY DEFAULT AS IDENTITY + ,withdrawal_uuid uuid NOT NULL PRIMARY KEY ,amount taler_amount NOT NULL ,selection_done BOOLEAN DEFAULT FALSE NOT NULL ,aborted BOOLEAN DEFAULT FALSE NOT NULL