libeufin

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

commit e1e8a3b2e28321a56ddc0206b7485e754e6f8f77
parent fbec8bebc30a4619b6b192a0fc466dc81f15da14
Author: Antoine A <>
Date:   Mon, 15 Apr 2024 16:28:24 +0900

nexus: wire gateway /transfer

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 4----
Mcommon/src/main/kotlin/db/transaction.kt | 9+++++++++
Mcommon/src/main/kotlin/db/types.kt | 6++++++
Adatabase-versioning/libeufin-nexus-0003.sql | 38++++++++++++++++++++++++++++++++++++++
Mdatabase-versioning/libeufin-nexus-procedures.sql | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 35+++++++++++++----------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt | 1+
Anexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt | 15+++++++++------
Mnexus/src/test/kotlin/DatabaseTest.kt | 22++++++++--------------
Mnexus/src/test/kotlin/WireGatewayApiTest.kt | 58+++++++++++++++++-----------------------------------------
Mnexus/src/test/kotlin/helpers.kt | 2+-
12 files changed, 247 insertions(+), 90 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -163,8 +163,4 @@ enum class AbortResult { Success, UnknownOperation, AlreadyConfirmed -} - -fun ResultSet.getTalerTimestamp(name: String): TalerProtocolTimestamp{ - return TalerProtocolTimestamp(getLong(name).asInstant()) } \ No newline at end of file diff --git a/common/src/main/kotlin/db/transaction.kt b/common/src/main/kotlin/db/transaction.kt @@ -52,6 +52,15 @@ fun <T> PreparedStatement.oneOrNull(lambda: (ResultSet) -> T): T? { fun <T> PreparedStatement.one(lambda: (ResultSet) -> T): T = requireNotNull(oneOrNull(lambda)) { "Missing result to database query" } +fun <T> PreparedStatement.oneUniqueViolation(err: T, lambda: (ResultSet) -> T): T { + return try { + one(lambda) + } catch (e: SQLException) { + if (e.sqlState == PSQLState.UNIQUE_VIOLATION.state) return err + throw e // rethrowing, not to hide other types of errors. + } +} + fun <T> PreparedStatement.all(lambda: (ResultSet) -> T): List<T> { executeQuery().use { val ret = mutableListOf<T>() diff --git a/common/src/main/kotlin/db/types.kt b/common/src/main/kotlin/db/types.kt @@ -22,6 +22,8 @@ package tech.libeufin.common.db import tech.libeufin.common.BankPaytoCtx import tech.libeufin.common.Payto import tech.libeufin.common.TalerAmount +import tech.libeufin.common.TalerProtocolTimestamp +import tech.libeufin.common.asInstant import java.sql.ResultSet fun ResultSet.getAmount(name: String, currency: String): TalerAmount { @@ -32,6 +34,10 @@ fun ResultSet.getAmount(name: String, currency: String): TalerAmount { ) } +fun ResultSet.getTalerTimestamp(name: String): TalerProtocolTimestamp{ + return TalerProtocolTimestamp(getLong(name).asInstant()) +} + fun ResultSet.getBankPayto(payto: String, name: String, ctx: BankPaytoCtx): String { return Payto.parse(getString(payto)).bank(getString(name), ctx) } \ No newline at end of file diff --git a/database-versioning/libeufin-nexus-0003.sql b/database-versioning/libeufin-nexus-0003.sql @@ -0,0 +1,38 @@ +-- +-- 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-0003', NULL, NULL); + +SET search_path TO libeufin_nexus; + +CREATE TABLE IF NOT EXISTS talerable_outgoing_transactions + (talerable_outgoing_transaction_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE + ,initiated_outgoing_transaction_id INT8 UNIQUE REFERENCES initiated_outgoing_transactions(initiated_outgoing_transaction_id) ON DELETE CASCADE + ,outgoing_transaction_id INT8 UNIQUE REFERENCES outgoing_transactions(outgoing_transaction_id) ON DELETE CASCADE + ,CONSTRAINT tx_link CHECK ((initiated_outgoing_transaction_id IS NULL) != (outgoing_transaction_id IS NULL)) + ,request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=64) + ,wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32) + ,exchange_base_url TEXT NOT NULL + ); +COMMENT ON COLUMN talerable_outgoing_transactions.initiated_outgoing_transaction_id + IS 'If the transaction have been initiated'; +COMMENT ON COLUMN talerable_outgoing_transactions.outgoing_transaction_id + IS 'If the transaction have been recovered'; +COMMENT ON CONSTRAINT tx_link ON talerable_outgoing_transactions + IS 'A transaction is either initiated or recovered'; + +COMMIT; diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -244,4 +244,67 @@ END $$; COMMENT ON FUNCTION register_incoming_and_talerable IS ' Creates one row in the incoming transactions table and one row in the talerable transactions table. The talerable row links the -incoming one.'; -\ No newline at end of file +incoming one.'; + +CREATE FUNCTION taler_transfer( + IN in_request_uid BYTEA, + IN in_wtid BYTEA, + IN in_subject TEXT, + IN in_amount taler_amount, + IN in_exchange_base_url TEXT, + IN in_credit_account_payto TEXT, + IN in_bank_id TEXT, + IN in_timestamp INT8, + -- Error status + OUT out_request_uid_reuse BOOLEAN, + -- Success return + OUT out_tx_row_id INT8, + OUT out_timestamp INT8 +) +LANGUAGE plpgsql AS $$ +DECLARE + initiated_id INT8; +BEGIN +-- Check for idempotence and conflict +SELECT (amount != in_amount + OR credit_payto_uri != in_credit_account_payto + OR exchange_base_url != in_exchange_base_url + OR wtid != in_wtid) + ,talerable_outgoing_transaction_id, initiation_time + INTO out_request_uid_reuse, out_tx_row_id, out_timestamp + FROM talerable_outgoing_transactions + JOIN initiated_outgoing_transactions + ON talerable_outgoing_transactions.initiated_outgoing_transaction_id=initiated_outgoing_transactions.initiated_outgoing_transaction_id + WHERE talerable_outgoing_transactions.request_uid = in_request_uid; +IF FOUND THEN + RETURN; +END IF; +-- Initiate bank transfer +INSERT INTO initiated_outgoing_transactions ( + amount + ,wire_transfer_subject + ,credit_payto_uri + ,initiation_time + ,request_uid +) VALUES ( + in_amount + ,in_subject + ,in_credit_account_payto + ,in_timestamp + ,in_bank_id +) RETURNING initiated_outgoing_transaction_id INTO initiated_id; +-- Register outgoing transaction +INSERT INTO talerable_outgoing_transactions( + initiated_outgoing_transaction_id + ,request_uid + ,wtid + ,exchange_base_url +) VALUES ( + initiated_id + ,in_request_uid + ,in_wtid + ,in_exchange_base_url +) RETURNING talerable_outgoing_transaction_id INTO out_tx_row_id; +out_timestamp = in_timestamp; +-- TODO notification +END $$; diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -29,6 +29,8 @@ import tech.libeufin.common.* import tech.libeufin.nexus.* import tech.libeufin.nexus.db.* import tech.libeufin.nexus.db.PaymentDAO.* +import tech.libeufin.nexus.db.InitiatedDAO.* +import tech.libeufin.nexus.db.ExchangeDAO.* import java.time.Instant @@ -41,38 +43,28 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) { post("/taler-wire-gateway/transfer") { val req = call.receive<TransferRequest>() cfg.checkCurrency(req.amount) - // TODO - /*val res = db.exchange.transfer( - req = req, - login = username, - now = Instant.now() + val bankId = run { + val bytes = ByteArray(16) + kotlin.random.Random.nextBytes(bytes) + Base32Crockford.encode(bytes) + } + val res = db.exchange.transfer( + req, + bankId, + Instant.now() ) when (res) { - is TransferResult.UnknownExchange -> throw unknownAccount(username) - is TransferResult.NotAnExchange -> throw conflict( - "$username is not an exchange account.", - TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE - ) - is TransferResult.UnknownCreditor -> throw unknownCreditorAccount(req.credit_account.canonical) - is TransferResult.BothPartyAreExchange -> throw conflict( - "Wire transfer attempted with credit and debit party being both exchange account", - TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE - ) - is TransferResult.ReserveUidReuse -> throw conflict( + TransferResult.RequestUidReuse -> throw conflict( "request_uid used already", TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED ) - is TransferResult.BalanceInsufficient -> throw conflict( - "Insufficient balance for exchange", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) is TransferResult.Success -> call.respond( TransferResponse( timestamp = res.timestamp, row_id = res.id ) ) - }*/ + } } /*suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint( reduce: (List<T>, String) -> Any, @@ -122,7 +114,6 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) { "reserve_pub used already", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) - // TODO timestamp when idempotent is IncomingRegistrationResult.Success -> call.respond( AddIncomingResponse( timestamp = TalerProtocolTimestamp(timestamp), diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt @@ -42,4 +42,5 @@ data class InitiatedPayment( class Database(dbConfig: DatabaseConfig): DbPool(dbConfig, "libeufin_nexus") { val payment = PaymentDAO(this) val initiated = InitiatedDAO(this) + val exchange = ExchangeDAO(this) } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt @@ -0,0 +1,80 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 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 + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus.db + +import tech.libeufin.common.db.one +import tech.libeufin.common.db.getTalerTimestamp +import tech.libeufin.common.micros +import tech.libeufin.common.TalerProtocolTimestamp +import tech.libeufin.common.TransferRequest +import java.sql.ResultSet +import java.time.Instant + +/** Data access logic for exchange specific logic */ +class ExchangeDAO(private val db: Database) { + + /** Result of taler transfer transaction creation */ + sealed interface TransferResult { + /** Transaction [id] and wire transfer [timestamp] */ + data class Success(val id: Long, val timestamp: TalerProtocolTimestamp): TransferResult + data object RequestUidReuse: TransferResult + } + + /** Perform a Taler transfer */ + suspend fun transfer( + req: TransferRequest, + bankId: String, + now: Instant + ): TransferResult = db.serializable { conn -> + val subject = "${req.wtid} ${req.exchange_base_url.url}" + val stmt = conn.prepareStatement(""" + SELECT + out_request_uid_reuse + ,out_tx_row_id + ,out_timestamp + FROM + taler_transfer ( + ?, ?, ?, + (?,?)::taler_amount, + ?, ?, ?, ? + ); + """) + + stmt.setBytes(1, req.request_uid.raw) + stmt.setBytes(2, req.wtid.raw) + stmt.setString(3, subject) + stmt.setLong(4, req.amount.value) + stmt.setInt(5, req.amount.frac) + stmt.setString(6, req.exchange_base_url.url) + stmt.setString(7, req.credit_account.canonical) + stmt.setString(8, bankId) + stmt.setLong(9, now.micros()) + + stmt.one { + when { + it.getBoolean("out_request_uid_reuse") -> TransferResult.RequestUidReuse + else -> TransferResult.Success( + id = it.getLong("out_tx_row_id"), + timestamp = it.getTalerTimestamp("out_timestamp") + ) + } + } + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt @@ -22,6 +22,7 @@ package tech.libeufin.nexus.db import tech.libeufin.common.asInstant import tech.libeufin.common.db.all import tech.libeufin.common.db.executeUpdateViolation +import tech.libeufin.common.db.oneUniqueViolation import tech.libeufin.common.db.getAmount import tech.libeufin.common.db.oneOrNull import tech.libeufin.common.micros @@ -32,9 +33,9 @@ import java.time.Instant class InitiatedDAO(private val db: Database) { /** Outgoing payments initiation result */ - enum class PaymentInitiationResult { - REQUEST_UID_REUSE, - SUCCESS + sealed interface PaymentInitiationResult { + data class Success(val id: Long): PaymentInitiationResult + data object RequestUidReuse: PaymentInitiationResult } /** Register a new pending payment in the database */ @@ -47,16 +48,18 @@ class InitiatedDAO(private val db: Database) { ,initiation_time ,request_uid ) VALUES ((?,?)::taler_amount,?,?,?,?) + RETURNING initiated_outgoing_transaction_id """) + // TODO check payto uri stmt.setLong(1, paymentData.amount.value) stmt.setInt(2, paymentData.amount.frac) stmt.setString(3, paymentData.wireTransferSubject) stmt.setString(4, paymentData.creditPaytoUri.toString()) stmt.setLong(5, paymentData.initiationTime.micros()) stmt.setString(6, paymentData.requestUid) - if (stmt.executeUpdateViolation()) - return@conn PaymentInitiationResult.SUCCESS - return@conn PaymentInitiationResult.REQUEST_UID_REUSE + stmt.oneUniqueViolation(PaymentInitiationResult.RequestUidReuse) { + PaymentInitiationResult.Success(it.getLong("initiated_outgoing_transaction_id")) + } } /** Register EBICS submission success */ diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -22,6 +22,7 @@ import tech.libeufin.common.TalerAmount import tech.libeufin.nexus.db.InitiatedDAO.PaymentInitiationResult import java.time.Instant import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -31,8 +32,7 @@ class OutgoingPaymentsTest { fun register() = setup { db, _ -> // With reconciling genOutPay("paid by nexus", "first").run { - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay("waiting for reconciliation", "first")) ) db.payment.registerOutgoing(this).run { @@ -117,8 +117,7 @@ class PaymentInitiationsTest { @Test fun status() = setup { db, _ -> - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY1")) ) db.initiated.submissionFailure(1, Instant.now(), "First failure") @@ -126,8 +125,7 @@ class PaymentInitiationsTest { db.initiated.submissionSuccess(1, Instant.now(), "ORDER1") assertEquals(Pair("PAY1", null), db.initiated.logFailure("ORDER1")) - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY2")) ) db.initiated.submissionFailure(2, Instant.now(), "First failure") @@ -135,8 +133,7 @@ class PaymentInitiationsTest { db.initiated.logMessage("ORDER2", "status msg") assertEquals(Pair("PAY2", "status msg"), db.initiated.logFailure("ORDER2")) - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY3")) ) db.initiated.submissionSuccess(3, Instant.now(), "ORDER3") @@ -146,15 +143,13 @@ class PaymentInitiationsTest { assertNull(db.initiated.logSuccess("ORDER_X")) assertNull(db.initiated.logFailure("ORDER_X")) - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY4")) ) db.initiated.bankMessage("PAY4", "status progress") db.initiated.bankFailure("PAY4", "status failure") - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY5")) ) db.initiated.bankMessage("PAY5", "status progress") @@ -164,8 +159,7 @@ class PaymentInitiationsTest { @Test fun submittable() = setup { db, _ -> for (i in 0..5) { - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY$i")) ) } diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -35,36 +35,30 @@ class WireGatewayApiTest { } // Testing the POST /transfer call from the TWG API. - /*@Test - fun transfer() = bankSetup { _ -> + @Test + fun transfer() = serverSetup { _ -> val valid_req = obj { "request_uid" to HashCode.rand() - "amount" to "KUDOS:55" + "amount" to "CHF:55" "exchange_base_url" to "http://exchange.example.com/" "wtid" to ShortHashCode.rand() - "credit_account" to merchantPayto.canonical + "credit_account" to grothoffPayto } - authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/transfer", valid_req) - - // Checking exchange debt constraint. - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { - json(valid_req) - }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) + //authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/transfer", valid_req) - // Giving debt allowance and checking the OK case. - setMaxDebt("exchange", "KUDOS:1000") - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + // Check OK + client.post("/taler-wire-gateway/transfer") { json(valid_req) }.assertOk() // check idempotency - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.post("/taler-wire-gateway/transfer") { json(valid_req) }.assertOk() // Trigger conflict due to reused request_uid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.post("/taler-wire-gateway/transfer") { json(valid_req) { "wtid" to ShortHashCode.rand() "exchange_base_url" to "http://different-exchange.example.com/" @@ -72,58 +66,40 @@ class WireGatewayApiTest { }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) // Currency mismatch - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.post("/taler-wire-gateway/transfer") { json(valid_req) { "amount" to "EUR:33" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) - // Unknown account - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { - json(valid_req) { - "request_uid" to HashCode.rand() - "wtid" to ShortHashCode.rand() - "credit_account" to unknownPayto - } - }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR) - - // Same account - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { - json(valid_req) { - "request_uid" to HashCode.rand() - "wtid" to ShortHashCode.rand() - "credit_account" to exchangePayto - } - }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) - // Bad BASE32 wtid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.post("/taler-wire-gateway/transfer") { json(valid_req) { "wtid" to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len wtid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.post("/taler-wire-gateway/transfer") { json(valid_req) { - "wtid" to randBase32Crockford(31) + "wtid" to Base32Crockford.encode(ByteArray(31).rand()) } }.assertBadRequest() // Bad BASE32 request_uid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.post("/taler-wire-gateway/transfer") { json(valid_req) { "request_uid" to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len wtid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.post("/taler-wire-gateway/transfer") { json(valid_req) { - "request_uid" to randBase32Crockford(65) + "request_uid" to Base32Crockford.encode(ByteArray(65).rand()) } }.assertBadRequest() - }*/ + } /* /** * Testing the /history/incoming call from the TWG API. diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -79,7 +79,7 @@ fun getMockedClient( followRedirects = false engine { addHandler { - request -> handler(request) + request -> handler(request) } } }