commit e1e8a3b2e28321a56ddc0206b7485e754e6f8f77
parent fbec8bebc30a4619b6b192a0fc466dc81f15da14
Author: Antoine A <>
Date: Mon, 15 Apr 2024 16:28:24 +0900
nexus: wire gateway /transfer
Diffstat:
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)
}
}
}