libeufin

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

commit ac77e1c832e43ec95665ffe6bbb9450c70495a3c
parent 0d1d6348442532f01a09781419e6a29c8e645ab5
Author: Antoine A <>
Date:   Tue, 17 Oct 2023 18:48:18 +0000

Fix withdrawal not being registered as incoming transaction

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt | 5++++-
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 32++++++++++++++------------------
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 6+++---
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 6+++---
Mbank/src/test/kotlin/DatabaseTest.kt | 4++--
Mbank/src/test/kotlin/WireGatewayApiTest.kt | 27+++++++++++++++++++++------
Mbank/src/test/kotlin/helpers.kt | 16++++------------
Mdatabase-versioning/libeufin-bank-0001.sql | 3++-
Mdatabase-versioning/procedures.sql | 46++++++++++++++++++++++++++++------------------
9 files changed, 81 insertions(+), 64 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt @@ -63,14 +63,17 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankApplicationContext) { val wopid = call.expectUriComponent("wopid") val req = call.receive<BankWithdrawalOperationPostRequest>() val op = getWithdrawal(db, wopid) // throws 404 if not found. + + // TODO move logic into DB if (op.selectionDone) { // idempotency if (op.selectedExchangePayto != req.selected_exchange && op.reservePub != req.reserve_pub) throw conflict( hint = "Cannot select different exchange and reserve pub. under the same withdrawal operation", talerEc = TalerErrorCode.TALER_EC_BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT ) } + // TODO check account is exchange val dbSuccess: Boolean = if (!op.selectionDone) { // Check if reserve pub. was used in _another_ withdrawal. - if (db.bankTransactionCheckExists(req.reserve_pub)) throw conflict( + if (db.checkReservePubReuse(req.reserve_pub)) throw conflict( "Reserve pub. already used", TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) db.talerWithdrawalSetDetails( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -847,20 +847,14 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos } } - /** - * Only checks if a bank transaction with the given subject - * exists. That's only used in the /admin/add-incoming, to - * prevent a public key from being reused. - * - * Returns the row ID if found, null otherwise. - */ - suspend fun bankTransactionCheckExists(subject: String): Boolean = conn { conn -> + suspend fun checkReservePubReuse(reservePub: EddsaPublicKey): Boolean = conn { conn -> val stmt = conn.prepareStatement(""" - SELECT 1 - FROM bank_account_transactions - WHERE subject = ?; + SELECT 1 FROM taler_exchange_incoming WHERE reserve_pub = ? + UNION ALL + SELECT 1 FROM taler_withdrawal_operations WHERE reserve_pub = ? """) - stmt.setString(1, subject) + stmt.setBytes(1, reservePub.raw) + stmt.setBytes(2, reservePub.raw) stmt.oneOrNull { } != null } @@ -1145,7 +1139,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos walletBankAccount = it.getLong("wallet_bank_account"), confirmationDone = it.getBoolean("confirmation_done"), aborted = it.getBoolean("aborted"), - reservePub = it.getString("reserve_pub"), + reservePub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), withdrawalUuid = it.getObject("withdrawal_uuid") as UUID ) } @@ -1177,17 +1171,19 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos suspend fun talerWithdrawalSetDetails( opUuid: UUID, exchangePayto: IbanPayTo, - reservePub: String + reservePub: EddsaPublicKey ): Boolean = conn { conn -> + val subject = IncomingTxMetadata(reservePub).encode() val stmt = conn.prepareStatement(""" UPDATE taler_withdrawal_operations - SET selected_exchange_payto = ?, reserve_pub = ?, selection_done = true + SET selected_exchange_payto = ?, reserve_pub = ?, subject = ?, selection_done = true WHERE withdrawal_uuid=? """ ) stmt.setString(1, exchangePayto.canonical) - stmt.setString(2, reservePub) - stmt.setObject(3, opUuid) + stmt.setBytes(2, reservePub.raw) + stmt.setString(3, subject) + stmt.setObject(4, opUuid) stmt.executeUpdateViolation() } @@ -1527,7 +1523,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos pmtInfId: String = "not used", endToEndId: String = "not used", ): TalerAddIncomingCreationResult = conn { conn -> - val subject = IncomingTxMetadata(req.reserve_pub).encode() + val subject = IncomingTxMetadata(req.reserve_pub).encode() val stmt = conn.prepareStatement(""" SELECT out_creditor_not_found diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -269,7 +269,7 @@ data class TalerWithdrawalOperation( val selectionDone: Boolean = false, val aborted: Boolean = false, val confirmationDone: Boolean = false, - val reservePub: String?, + val reservePub: EddsaPublicKey?, val selectedExchangePayto: IbanPayTo?, val walletBankAccount: Long ) @@ -399,7 +399,7 @@ data class BankAccountGetWithdrawalResponse( val aborted: Boolean, val confirmation_done: Boolean, val selection_done: Boolean, - val selected_reserve_pub: String? = null, + val selected_reserve_pub: EddsaPublicKey? = null, val selected_exchange_account: IbanPayTo? = null ) @@ -467,7 +467,7 @@ data class BankWithdrawalOperationStatus( */ @Serializable data class BankWithdrawalOperationPostRequest( - val reserve_pub: String, + val reserve_pub: EddsaPublicKey, val selected_exchange: IbanPayTo, ) diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -30,7 +30,7 @@ class BankIntegrationApiTest { val r = client.post("/taler-integration/withdrawal-operation/${uuid}") { jsonBody(BankWithdrawalOperationPostRequest( - reserve_pub = "RESERVE-FOO", + reserve_pub = randEddsaPublicKey(), selected_exchange = IbanPayTo("payto://iban/ABC123") )) }.assertOk() @@ -64,7 +64,7 @@ class BankIntegrationApiTest { )) val op = db.talerWithdrawalGet(uuid) assert(op?.aborted == false) - assert(db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/exchange-payto"), "reserve_pub")) + assert(db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/exchange-payto"), randEddsaPublicKey())) client.post("/withdrawals/${uuid}/abort") { basicAuth("merchant", "merchant-password") @@ -103,7 +103,7 @@ class BankIntegrationApiTest { assert(db.talerWithdrawalSetDetails( opUuid = uuid, exchangePayto = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"), - reservePub = "UNCHECKED-RESERVE-PUB" + reservePub = randEddsaPublicKey() )) // Starting the bank and POSTing as Foo to /confirm the operation. diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -291,8 +291,8 @@ class DatabaseTest { // Setting the details. assert(db.talerWithdrawalSetDetails( opUuid = uuid, - exchangePayto = IbanPayTo("payto://iban/BAR-IBAN-ABC"), - reservePub = "UNCHECKED-RESERVE-PUB" + exchangePayto = IbanPayTo("payto://iban/FOO-IBAN-XYZ"), + reservePub = randEddsaPublicKey() )) val opSelected = db.talerWithdrawalGet(uuid) assert(opSelected?.selectionDone == true && !opSelected.confirmationDone) diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -238,12 +238,27 @@ class WireGatewayApiTest { db.bankTransactionCreate(genTx("bogus foobar")).assertSuccess() // Bar pays Foo once, but that should not appear in the result. db.bankTransactionCreate(genTx("payout", creditorId = 1, debtorId = 2)).assertSuccess() - // Gen two transactions using row bank transaction logic - repeat(2) { - db.bankTransactionCreate( - genTx(IncomingTxMetadata(randShortHashCode()).encode(), 2, 1) - ).assertSuccess() + // Gen one transaction using raw bank transaction logic + db.bankTransactionCreate( + genTx(IncomingTxMetadata(randShortHashCode()).encode(), 2, 1) + ).assertSuccess() + // Gen one transaction using withdraw logic + val uuid = client.post("/accounts/merchant/withdrawals") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "amount" to "KUDOS:9" }) + }.assertOk().run { + val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText()) + resp.taler_withdraw_uri.split("/").last() } + client.post("/taler-integration/withdrawal-operation/${uuid}") { + jsonBody(json { + "reserve_pub" to randEddsaPublicKey() + "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + }) + }.assertOk() + client.post("/withdrawals/${uuid}/confirm") { + basicAuth("merchant", "merchant-password") + }.assertOk() // Check ignore bogus subject client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7") { @@ -373,7 +388,7 @@ class WireGatewayApiTest { db.bankTransactionCreate(genTx("bogus foobar", 1, 2)).assertSuccess() // Foo pays Bar once, but that should not appear in the result. db.bankTransactionCreate(genTx("payout")).assertSuccess() - // Gen two transactions using row bank transaction logic + // Gen two transactions using raw bank transaction logic repeat(2) { db.bankTransactionCreate( genTx(OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode(), 1, 2) diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -171,14 +171,6 @@ fun randBase32Crockford(lenght: Int): String { return Base32Crockford.encode(bytes) } -fun randHashCode(): HashCode { - return HashCode(randBase32Crockford(64)) -} - -fun randShortHashCode(): ShortHashCode { - return ShortHashCode(randBase32Crockford(32)) -} - -fun randEddsaPublicKey(): EddsaPublicKey { - return EddsaPublicKey(randBase32Crockford(32)) -} -\ No newline at end of file +fun randHashCode(): HashCode = HashCode(randBase32Crockford(64)) +fun randShortHashCode(): ShortHashCode = ShortHashCode(randBase32Crockford(32)) +fun randEddsaPublicKey(): EddsaPublicKey = EddsaPublicKey(randBase32Crockford(32)) +\ No newline at end of file diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -385,7 +385,8 @@ CREATE TABLE IF NOT EXISTS taler_withdrawal_operations ,selection_done BOOLEAN DEFAULT FALSE NOT NULL ,aborted BOOLEAN DEFAULT FALSE NOT NULL ,confirmation_done BOOLEAN DEFAULT FALSE NOT NULL - ,reserve_pub TEXT NULL -- Kotlin must check it's valid. + ,reserve_pub BYTEA UNIQUE CHECK (LENGTH(reserve_pub)=32) + ,subject TEXT ,selected_exchange_payto TEXT ,wallet_bank_account BIGINT NOT NULL REFERENCES bank_accounts(bank_account_id) diff --git a/database-versioning/procedures.sql b/database-versioning/procedures.sql @@ -348,12 +348,10 @@ DECLARE exchange_bank_account_id BIGINT; sender_bank_account_id BIGINT; BEGIN --- Check for idempotence and conflict -SELECT true - FROM taler_exchange_incoming - JOIN bank_account_transactions AS txs - ON bank_transaction=txs.bank_transaction_id - WHERE reserve_pub = in_reserve_pub +-- 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; @@ -590,6 +588,7 @@ CREATE OR REPLACE FUNCTION confirm_taler_withdrawal( IN in_end_to_end_id TEXT, OUT out_no_op BOOLEAN, OUT out_balance_insufficient BOOLEAN, + OUT out_creditor_not_found BOOLEAN, OUT out_exchange_not_found BOOLEAN, OUT out_already_confirmed_conflict BOOLEAN ) @@ -597,22 +596,23 @@ LANGUAGE plpgsql AS $$ DECLARE confirmation_done_local BOOLEAN; - reserve_pub_local TEXT; + subject_local TEXT; + reserve_pub_local BYTEA; selected_exchange_payto_local TEXT; wallet_bank_account_local BIGINT; amount_local taler_amount; exchange_bank_account_id BIGINT; - maybe_balance_insufficient BOOLEAN; + tx_row_id BIGINT; BEGIN SELECT -- Really no-star policy and instead DECLARE almost one var per column? confirmation_done, - reserve_pub, + reserve_pub, subject, selected_exchange_payto, wallet_bank_account, (amount).val, (amount).frac INTO confirmation_done_local, - reserve_pub_local, + reserve_pub_local, subject_local, selected_exchange_payto_local, wallet_bank_account_local, amount_local.val, amount_local.frac @@ -647,24 +647,34 @@ THEN END IF; out_exchange_not_found=FALSE; SELECT -- not checking for accounts existence, as it was done above. - transfer.out_balance_insufficient - INTO - out_balance_insufficient + transfer.out_balance_insufficient, + out_credit_row_id + INTO out_balance_insufficient, tx_row_id FROM bank_wire_transfer( exchange_bank_account_id, wallet_bank_account_local, - reserve_pub_local, + subject_local, amount_local, in_confirmation_date, in_acct_svcr_ref, in_pmt_inf_id, in_end_to_end_id ) as transfer; -IF (maybe_balance_insufficient) -THEN - out_balance_insufficient=TRUE; +IF out_balance_insufficient THEN + RETURN; END IF; -out_balance_insufficient=FALSE; + +-- Register incoming transaction +INSERT + INTO taler_exchange_incoming ( + reserve_pub, + bank_transaction +) VALUES ( + reserve_pub_local, + tx_row_id +); +-- notify new transaction +PERFORM pg_notify('incoming_tx', exchange_bank_account_id || ' ' || tx_row_id); END $$; COMMENT ON FUNCTION confirm_taler_withdrawal(uuid, bigint, text, text, text) IS 'Set a withdrawal operation as confirmed and wire the funds to the exchange.';