commit af3b104fb49d3066bd72102b3b0996fc39cdbf75 parent ed18c6cb21f98a71c9cec2bd1cc2b84ae1520b7d Author: Antoine A <> Date: Tue, 9 Apr 2024 15:43:48 +0200 nexus: wire gateway /admin/add-incoming Diffstat:
29 files changed, 679 insertions(+), 239 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt @@ -40,5 +40,4 @@ const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5 const val COREBANK_API_VERSION: String = "4:7:0" const val CONVERSION_API_VERSION: String = "0:0:0" const val INTEGRATION_API_VERSION: String = "2:0:2" -const val WIRE_GATEWAY_API_VERSION: String = "0:2:0" const val REVENUE_API_VERSION: String = "0:0:0" \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -37,50 +37,6 @@ import java.time.Instant import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit -/** Timestamp containing the number of seconds since epoch */ -@Serializable -data class TalerProtocolTimestamp( - @Serializable(with = Serializer::class) - val t_s: Instant, -) { - companion object { - fun fromMicroseconds(uSec: Long): TalerProtocolTimestamp { - return TalerProtocolTimestamp( - Instant.EPOCH.plus(uSec, ChronoUnit.MICROS) - ) - } - } - - internal object Serializer : KSerializer<Instant> { - override fun serialize(encoder: Encoder, value: Instant) { - if (value == Instant.MAX) { - encoder.encodeString("never") - } else { - encoder.encodeLong(value.epochSecond) - } - - } - - override fun deserialize(decoder: Decoder): Instant { - val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") - val maybeTs = jsonInput.decodeJsonElement().jsonPrimitive - if (maybeTs.isString) { - if (maybeTs.content != "never") throw badRequest("Only 'never' allowed for t_s as string, but '${maybeTs.content}' was found") - return Instant.MAX - } - val ts: Long = maybeTs.longOrNull - ?: throw badRequest("Could not convert t_s '${maybeTs.content}' to a number") - when { - ts < 0 -> throw badRequest("Negative timestamp not allowed") - ts > Instant.MAX.epochSecond -> throw badRequest("Timestamp $ts too big to be represented in Kotlin") - else -> return Instant.ofEpochSecond(ts) - } - } - - override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor - } -} - @Serializable(with = DecimalNumber.Serializer::class) class DecimalNumber { val value: Long @@ -180,29 +136,4 @@ data class RelativeTime( companion object { private const val MAX_SAFE_INTEGER = 9007199254740991L // 2^53 - 1 } -} - - -@Serializable(with = ExchangeUrl.Serializer::class) -class ExchangeUrl { - val url: String - - constructor(raw: String) { - url = URL(raw).toString() - } - - override fun toString(): String = url - - internal object Serializer : KSerializer<ExchangeUrl> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("ExchangeUrl", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: ExchangeUrl) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): ExchangeUrl { - return ExchangeUrl(decoder.decodeString()) - } - } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -328,14 +328,6 @@ data class TalerIntegrationConfigResponse( } @Serializable -data class WireGatewayConfig( - val currency: String -) { - val name: String = "taler-wire-gateway" - val version: String = WIRE_GATEWAY_API_VERSION -} - -@Serializable data class RevenueConfig( val currency: String ) { @@ -554,70 +546,6 @@ data class ConversionResponse( val amount_credit: TalerAmount, ) -/** - * Request to an /admin/add-incoming request from - * the Taler Wire Gateway API. - */ -@Serializable -data class AddIncomingRequest( - val amount: TalerAmount, - val reserve_pub: EddsaPublicKey, - val debit_account: Payto -) - -/** - * Response to /admin/add-incoming - */ -@Serializable -data class AddIncomingResponse( - val timestamp: TalerProtocolTimestamp, - val row_id: Long -) - -/** - * Response of a TWG /history/incoming call. - */ -@Serializable -data class IncomingHistory( - val incoming_transactions: List<IncomingReserveTransaction>, - val credit_account: String -) - -/** - * TWG's incoming payment record. - */ -@Serializable -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, - val reserve_pub: EddsaPublicKey -) - -/** - * Response of a TWG /history/outgoing call. - */ -@Serializable -data class OutgoingHistory( - val outgoing_transactions: List<OutgoingTransaction>, - val debit_account: String -) - -/** - * TWG's outgoinf payment record. - */ -@Serializable -data class OutgoingTransaction( - val row_id: Long, // DB row ID of the payment. - val date: TalerProtocolTimestamp, - val amount: TalerAmount, - val credit_account: String, - val wtid: ShortHashCode, - val exchange_base_url: String, -) - @Serializable data class RevenueIncomingHistory( val incoming_transactions : List<RevenueIncomingBankTransaction>, @@ -634,27 +562,6 @@ data class RevenueIncomingBankTransaction( ) /** - * TWG's request to pay a merchant. - */ -@Serializable -data class TransferRequest( - val request_uid: HashCode, - val amount: TalerAmount, - val exchange_base_url: ExchangeUrl, - val wtid: ShortHashCode, - val credit_account: Payto -) - -/** - * TWG's response to merchant payouts - */ -@Serializable -data class TransferResponse( - val timestamp: TalerProtocolTimestamp, - val row_id: Long -) - -/** * Response to GET /public-accounts */ @Serializable diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -194,10 +194,8 @@ class ExchangeDAO(private val db: Database) { stmt.setString(6, login) stmt.setLong(7, now.micros()) - stmt.executeQuery().use { + stmt.one { when { - !it.next() -> - throw internalServerError("SQL function taler_add_incoming did not return anything.") it.getBoolean("out_creditor_not_found") -> AddIncomingResult.UnknownExchange it.getBoolean("out_creditor_not_exchange") -> AddIncomingResult.NotAnExchange it.getBoolean("out_debtor_not_found") -> AddIncomingResult.UnknownDebtor diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -21,11 +21,8 @@ import org.junit.Test import tech.libeufin.bank.DecimalNumber import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.TalerErrorCode +import tech.libeufin.common.* import tech.libeufin.common.db.oneOrNull -import tech.libeufin.common.json -import tech.libeufin.common.obj import java.time.Instant import java.util.* import kotlin.test.assertEquals diff --git a/bank/src/test/kotlin/CommonApiTest.kt b/bank/src/test/kotlin/CommonApiTest.kt @@ -20,7 +20,7 @@ import io.ktor.client.request.* import io.ktor.http.* import org.junit.Test -import tech.libeufin.common.TalerErrorCode +import tech.libeufin.common.* class CommonApiTest { @Test diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/bank/src/test/kotlin/ConversionApiTest.kt @@ -20,8 +20,7 @@ import io.ktor.client.request.* import org.junit.Test import tech.libeufin.bank.ConversionResponse -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.TalerErrorCode +import tech.libeufin.common.* import kotlin.test.assertEquals class ConversionApiTest { diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -23,6 +23,7 @@ import org.junit.Test import tech.libeufin.bank.createAdminAccount import tech.libeufin.bank.db.AccountDAO.AccountCreationResult import tech.libeufin.common.db.oneOrNull +import tech.libeufin.common.* import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt @@ -23,7 +23,7 @@ import kotlinx.serialization.json.Json import org.junit.Test import tech.libeufin.bank.CreditDebitInfo import tech.libeufin.bank.RelativeTime -import tech.libeufin.bank.TalerProtocolTimestamp +import tech.libeufin.common.TalerProtocolTimestamp import tech.libeufin.common.TalerAmount import java.time.Duration import java.time.Instant diff --git a/bank/src/test/kotlin/PaytoTest.kt b/bank/src/test/kotlin/PaytoTest.kt @@ -22,9 +22,7 @@ import org.junit.Test import tech.libeufin.bank.BankAccountTransactionInfo import tech.libeufin.bank.RegisterAccountResponse import tech.libeufin.bank.TransactionCreateResponse -import tech.libeufin.common.IbanPayto -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.json +import tech.libeufin.common.* import kotlin.test.assertEquals class PaytoTest { diff --git a/bank/src/test/kotlin/RevenueApiTest.kt b/bank/src/test/kotlin/RevenueApiTest.kt @@ -20,6 +20,7 @@ import io.ktor.http.* import org.junit.Test import tech.libeufin.bank.RevenueIncomingHistory +import tech.libeufin.common.* class RevenueApiTest { // GET /accounts/{USERNAME}/taler-revenue/config diff --git a/bank/src/test/kotlin/SecurityTest.kt b/bank/src/test/kotlin/SecurityTest.kt @@ -21,10 +21,7 @@ import io.ktor.client.request.* import io.ktor.http.* import kotlinx.serialization.json.Json import org.junit.Test -import tech.libeufin.common.TalerErrorCode -import tech.libeufin.common.deflate -import tech.libeufin.common.json -import tech.libeufin.common.obj +import tech.libeufin.common.* inline fun <reified B> HttpRequestBuilder.jsonDeflate(b: B) { val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b) diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -19,8 +19,6 @@ import io.ktor.http.* import org.junit.Test -import tech.libeufin.bank.IncomingHistory -import tech.libeufin.bank.OutgoingHistory import tech.libeufin.common.* class WireGatewayApiTest { diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -316,33 +316,6 @@ suspend fun tanCode(info: String): String? { /* ----- Assert ----- */ -suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: TalerErrorCode?): HttpResponse { - assertEquals(status, this.status, "$err") - if (err != null) { - val body = json<TalerError>() - assertEquals(err.code, body.code) - } - return this -} -suspend fun HttpResponse.assertOk(): HttpResponse - = assertStatus(HttpStatusCode.OK, null) -suspend fun HttpResponse.assertNoContent(): HttpResponse - = assertStatus(HttpStatusCode.NoContent, null) -suspend fun HttpResponse.assertAccepted(): HttpResponse - = assertStatus(HttpStatusCode.Accepted, null) -suspend fun HttpResponse.assertNotFound(err: TalerErrorCode): HttpResponse - = assertStatus(HttpStatusCode.NotFound, err) -suspend fun HttpResponse.assertUnauthorized(err: TalerErrorCode = TalerErrorCode.GENERIC_UNAUTHORIZED): HttpResponse - = assertStatus(HttpStatusCode.Unauthorized, err) -suspend fun HttpResponse.assertConflict(err: TalerErrorCode): HttpResponse - = assertStatus(HttpStatusCode.Conflict, err) -suspend fun HttpResponse.assertBadRequest(err: TalerErrorCode = TalerErrorCode.GENERIC_JSON_INVALID): HttpResponse - = assertStatus(HttpStatusCode.BadRequest, err) -suspend fun HttpResponse.assertForbidden(err: TalerErrorCode): HttpResponse - = assertStatus(HttpStatusCode.Forbidden, err) -suspend fun HttpResponse.assertNotImplemented(err: TalerErrorCode = TalerErrorCode.END): HttpResponse - = assertStatus(HttpStatusCode.NotImplemented, err) - suspend fun HttpResponse.maybeChallenge(): HttpResponse { return if (this.status == HttpStatusCode.Accepted) { this.assertChallenge() diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt @@ -27,9 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonObject import tech.libeufin.bank.BankAccountCreateWithdrawalResponse import tech.libeufin.bank.WithdrawalStatus -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.TalerErrorCode -import tech.libeufin.common.json +import tech.libeufin.common.* import kotlin.test.assertEquals // Test endpoint is correctly authenticated diff --git a/common/src/main/kotlin/Client.kt b/common/src/main/kotlin/Client.kt @@ -67,4 +67,33 @@ suspend inline fun <reified B> HttpResponse.assertOkJson(lambda: (B) -> Unit = { val body = json<B>() lambda(body) return body -} -\ No newline at end of file +} + +/* ----- Assert ----- */ + +suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: TalerErrorCode?): HttpResponse { + assertEquals(status, this.status, "$err") + if (err != null) { + val body = json<TalerError>() + assertEquals(err.code, body.code) + } + return this +} +suspend fun HttpResponse.assertOk(): HttpResponse + = assertStatus(HttpStatusCode.OK, null) +suspend fun HttpResponse.assertNoContent(): HttpResponse + = assertStatus(HttpStatusCode.NoContent, null) +suspend fun HttpResponse.assertAccepted(): HttpResponse + = assertStatus(HttpStatusCode.Accepted, null) +suspend fun HttpResponse.assertNotFound(err: TalerErrorCode): HttpResponse + = assertStatus(HttpStatusCode.NotFound, err) +suspend fun HttpResponse.assertUnauthorized(err: TalerErrorCode = TalerErrorCode.GENERIC_UNAUTHORIZED): HttpResponse + = assertStatus(HttpStatusCode.Unauthorized, err) +suspend fun HttpResponse.assertConflict(err: TalerErrorCode): HttpResponse + = assertStatus(HttpStatusCode.Conflict, err) +suspend fun HttpResponse.assertBadRequest(err: TalerErrorCode = TalerErrorCode.GENERIC_JSON_INVALID): HttpResponse + = assertStatus(HttpStatusCode.BadRequest, err) +suspend fun HttpResponse.assertForbidden(err: TalerErrorCode): HttpResponse + = assertStatus(HttpStatusCode.Forbidden, err) +suspend fun HttpResponse.assertNotImplemented(err: TalerErrorCode = TalerErrorCode.END): HttpResponse + = assertStatus(HttpStatusCode.NotImplemented, err) diff --git a/common/src/main/kotlin/Constants.kt b/common/src/main/kotlin/Constants.kt @@ -23,4 +23,7 @@ const val MIN_VERSION: Int = 14 const val SERIALIZATION_RETRY: Int = 10 // Security -const val MAX_BODY_LENGTH: Long = 4 * 1024 // 4kB -\ No newline at end of file +const val MAX_BODY_LENGTH: Long = 4 * 1024 // 4kB + +// API version +const val WIRE_GATEWAY_API_VERSION: String = "0:2:0" +\ No newline at end of file diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -27,8 +27,17 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull import tech.libeufin.common.* import java.net.URI +import java.net.URL +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit sealed class CommonError(msg: String): Exception(msg) { class AmountFormat(msg: String): CommonError(msg) @@ -36,6 +45,76 @@ sealed class CommonError(msg: String): Exception(msg) { class Payto(msg: String): CommonError(msg) } + +/** Timestamp containing the number of seconds since epoch */ +@Serializable +data class TalerProtocolTimestamp( + @Serializable(with = Serializer::class) + val t_s: Instant, +) { + companion object { + fun fromMicroseconds(uSec: Long): TalerProtocolTimestamp { + return TalerProtocolTimestamp( + Instant.EPOCH.plus(uSec, ChronoUnit.MICROS) + ) + } + } + + internal object Serializer : KSerializer<Instant> { + override fun serialize(encoder: Encoder, value: Instant) { + if (value == Instant.MAX) { + encoder.encodeString("never") + } else { + encoder.encodeLong(value.epochSecond) + } + + } + + override fun deserialize(decoder: Decoder): Instant { + val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") + val maybeTs = jsonInput.decodeJsonElement().jsonPrimitive + if (maybeTs.isString) { + if (maybeTs.content != "never") throw badRequest("Only 'never' allowed for t_s as string, but '${maybeTs.content}' was found") + return Instant.MAX + } + val ts: Long = maybeTs.longOrNull + ?: throw badRequest("Could not convert t_s '${maybeTs.content}' to a number") + when { + ts < 0 -> throw badRequest("Negative timestamp not allowed") + ts > Instant.MAX.epochSecond -> throw badRequest("Timestamp $ts too big to be represented in Kotlin") + else -> return Instant.ofEpochSecond(ts) + } + } + + override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor + } +} + + +@Serializable(with = ExchangeUrl.Serializer::class) +class ExchangeUrl { + val url: String + + constructor(raw: String) { + url = URL(raw).toString() + } + + override fun toString(): String = url + + internal object Serializer : KSerializer<ExchangeUrl> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ExchangeUrl", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ExchangeUrl) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): ExchangeUrl { + return ExchangeUrl(decoder.decodeString()) + } + } +} + @Serializable(with = TalerAmount.Serializer::class) class TalerAmount { val value: Long diff --git a/common/src/main/kotlin/TalerMessage.kt b/common/src/main/kotlin/TalerMessage.kt @@ -0,0 +1,103 @@ +/* + * 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.common + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** Response GET /taler-wire-gateway/config */ +@Serializable +data class WireGatewayConfig( + val currency: String +) { + val name: String = "taler-wire-gateway" + val version: String = WIRE_GATEWAY_API_VERSION +} + +/** Request POST /taler-wire-gateway/transfer */ +@Serializable +data class TransferRequest( + val request_uid: HashCode, + val amount: TalerAmount, + val exchange_base_url: ExchangeUrl, + val wtid: ShortHashCode, + val credit_account: Payto +) + +/** Response POST /taler-wire-gateway/transfer */ +@Serializable +data class TransferResponse( + val timestamp: TalerProtocolTimestamp, + val row_id: Long +) + +/** Request POST /taler-wire-gateway/admin/add-incoming */ +@Serializable +data class AddIncomingRequest( + val amount: TalerAmount, + val reserve_pub: EddsaPublicKey, + val debit_account: Payto +) + +/** Response POST /taler-wire-gateway/admin/add-incoming */ +@Serializable +data class AddIncomingResponse( + val timestamp: TalerProtocolTimestamp, + val row_id: Long +) + +/** Request GET /taler-wire-gateway/history/incoming */ +@Serializable +data class IncomingHistory( + val incoming_transactions: List<IncomingReserveTransaction>, + val credit_account: String +) + +@Serializable +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, + val reserve_pub: EddsaPublicKey +) + +/** Request GET /taler-wire-gateway/history/outgoing */ +@Serializable +data class OutgoingHistory( + val outgoing_transactions: List<OutgoingTransaction>, + val debit_account: String +) + +@Serializable +data class OutgoingTransaction( + val row_id: Long, // DB row ID of the payment. + val date: TalerProtocolTimestamp, + val amount: TalerAmount, + val credit_account: String, + val wtid: ShortHashCode, + val exchange_base_url: String, +) diff --git a/common/src/test/kotlin/AmountTest.kt b/common/src/test/kotlin/AmountTest.kt @@ -18,7 +18,7 @@ */ import org.junit.Test -import tech.libeufin.common.TalerAmount +import tech.libeufin.common.* import kotlin.test.assertEquals class AmountTest { diff --git a/contrib/nexus.conf b/contrib/nexus.conf @@ -57,15 +57,14 @@ FREQUENCY = 30m [nexus-httpd] PORT = 8080 -UNIXPATH = -SERVE = tcp | unix +SERVE = tcp -[nexus-httpd-wire-gateway-facade] -ENABLED = YES +[nexus-httpd-wire-gateway-api] +ENABLED = NO AUTH_METHOD = token AUTH_TOKEN = -[nexus-httpd-revenue-facade] -ENABLED = YES +[nexus-httpd-revenue-api] +ENABLED = NO AUTH_METHOD = token AUTH_TOKEN = diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -205,17 +205,31 @@ CREATE FUNCTION register_incoming_and_talerable( ,IN in_debit_payto_uri TEXT ,IN in_bank_id TEXT ,IN in_reserve_public_key BYTEA + -- Error status + ,OUT out_reserve_pub_reuse BOOLEAN + -- Success return ,OUT out_found BOOLEAN ,OUT out_tx_id INT8 ) LANGUAGE plpgsql AS $$ BEGIN +-- Check conflict +IF EXISTS ( + SELECT FROM talerable_incoming_transactions + JOIN incoming_transactions ON talerable_incoming_transactions.incoming_transaction_id=incoming_transactions.incoming_transaction_id + WHERE reserve_public_key = in_reserve_public_key + AND bank_id != in_bank_id +) THEN + out_reserve_pub_reuse = TRUE; + RETURN; +END IF; + -- Register the incoming transaction SELECT reg.out_found, reg.out_tx_id FROM register_incoming(in_amount, in_wire_transfer_subject, in_execution_time, in_debit_payto_uri, in_bank_id) as reg INTO out_found, out_tx_id; --- Register as talerable bounce +-- Register as talerable IF NOT EXISTS(SELECT 1 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 ( diff --git a/nexus/build.gradle b/nexus/build.gradle @@ -27,6 +27,7 @@ dependencies { implementation("com.github.ajalt.clikt:clikt:$clikt_version") implementation("org.postgresql:postgresql:$postgres_version") // Ktor client library + implementation("io.ktor:ktor-server-core:$ktor_version") implementation("io.ktor:ktor-client-cio:$ktor_version") // PDF generation @@ -40,6 +41,7 @@ dependencies { // Unit testing testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + testImplementation("io.ktor:ktor-server-test-host:$ktor_version") testImplementation("io.ktor:ktor-client-mock:$ktor_version") } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -28,6 +28,7 @@ import io.ktor.client.plugins.* import kotlinx.coroutines.* import tech.libeufin.common.* import tech.libeufin.nexus.db.* +import tech.libeufin.nexus.db.PaymentDAO.* import tech.libeufin.nexus.ebics.* import java.io.IOException import java.io.InputStream @@ -120,11 +121,16 @@ suspend fun ingestIncomingPayment( ) { runCatching { parseIncomingTxMetadata(payment.wireTransferSubject) }.fold( onSuccess = { reservePub -> - val result = db.payment.registerTalerableIncoming(payment, reservePub) - if (result.new) { - logger.info("$payment") - } else { - logger.debug("$payment already seen") + val res = db.payment.registerTalerableIncoming(payment, reservePub) + when (res) { + IncomingRegistrationResult.ReservePubReuse -> throw Error("TODO reserve pub reuse") + is IncomingRegistrationResult.Success -> { + if (res.new) { + logger.info("$payment") + } else { + logger.debug("$payment already seen") + } + } } }, onFailure = { e -> diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -33,10 +33,13 @@ import com.github.ajalt.clikt.parameters.groups.provideDelegate import com.github.ajalt.clikt.parameters.options.convert import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.versionOption +import io.ktor.server.application.* import org.slf4j.Logger import org.slf4j.LoggerFactory import tech.libeufin.common.* +import tech.libeufin.common.api.* import tech.libeufin.common.db.DatabaseConfig +import tech.libeufin.nexus.api.* import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.db.InitiatedPayment import java.nio.file.Path @@ -105,6 +108,16 @@ class NexusConfig(val config: TalerConfig) { val fetch = NexusFetchConfig(config) } +fun NexusConfig.checkCurrency(amount: TalerAmount) { + if (amount.currency != currency) throw badRequest( + "Wrong currency: expected regional $currency got ${amount.currency}", + TalerErrorCode.GENERIC_CURRENCY_MISMATCH + ) +} + +fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(logger) { + wireGatewayApi(db, cfg) +} /** * Abstracts the config loading diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -0,0 +1,134 @@ +/* + * 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.api + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.pipeline.* +import tech.libeufin.common.* +import tech.libeufin.nexus.* +import tech.libeufin.nexus.db.* +import tech.libeufin.nexus.db.PaymentDAO.* +import java.time.Instant + + +fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) { + get("/taler-wire-gateway/config") { + call.respond(WireGatewayConfig( + currency = cfg.currency + )) + } + 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() + ) + 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( + "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, + dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T> + ) { + val params = HistoryParams.extract(context.request.queryParameters) + val bankAccount = call.bankInfo(db, ctx.payto) + + if (!bankAccount.isTalerExchange) + throw conflict( + "$username is not an exchange account.", + TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE + ) + + val items = db.exchange.dbLambda(params, bankAccount.bankAccountId, ctx.payto) + + if (items.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(reduce(items, bankAccount.payto)) + } + }*/ + /*get("/taler-wire-gateway/history/incoming") { + historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory) + } + 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) + val timestamp = Instant.now() + val bankId = run { + val bytes = ByteArray(16) + kotlin.random.Random.nextBytes(bytes) + 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(), + bankId = bankId + ), req.reserve_pub) + when (res) { + IncomingRegistrationResult.ReservePubReuse -> throw conflict( + "reserve_pub used already", + TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + // TODO timestamp when idempotent + is IncomingRegistrationResult.Success -> call.respond( + AddIncomingResponse( + timestamp = TalerProtocolTimestamp(timestamp), + row_id = res.id + ) + ) + } + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -96,10 +96,10 @@ class PaymentDAO(private val db: Database) { } /** Incoming payments registration result */ - data class IncomingRegistrationResult( - val id: Long, - val new: Boolean - ) + sealed interface IncomingRegistrationResult { + data class Success(val id: Long, val new: Boolean): IncomingRegistrationResult + data object ReservePubReuse: IncomingRegistrationResult + } /** Register an talerable incoming payment */ suspend fun registerTalerableIncoming( @@ -107,7 +107,7 @@ class PaymentDAO(private val db: Database) { reservePub: EddsaPublicKey ): IncomingRegistrationResult = db.conn { conn -> val stmt = conn.prepareStatement(""" - SELECT out_found, out_tx_id + SELECT out_reserve_pub_reuse, out_found, out_tx_id FROM register_incoming_and_talerable((?,?)::taler_amount,?,?,?,?,?) """) val executionTime = paymentData.executionTime.micros() @@ -119,10 +119,13 @@ class PaymentDAO(private val db: Database) { stmt.setString(6, paymentData.bankId) stmt.setBytes(7, reservePub.raw) stmt.one { - IncomingRegistrationResult( - it.getLong("out_tx_id"), - !it.getBoolean("out_found") - ) + when { + it.getBoolean("out_reserve_pub_reuse") -> IncomingRegistrationResult.ReservePubReuse + else -> IncomingRegistrationResult.Success( + it.getLong("out_tx_id"), + !it.getBoolean("out_found") + ) + } } } } \ No newline at end of file diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -0,0 +1,240 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 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/> + */ + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.Test +import tech.libeufin.common.* + +class WireGatewayApiTest { + // GET /accounts/{USERNAME}/taler-wire-gateway/config + @Test + fun config() = serverSetup { _ -> + //authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/config") + + client.get("/taler-wire-gateway/config").assertOk() + } + + // Testing the POST /transfer call from the TWG API. + /*@Test + fun transfer() = bankSetup { _ -> + val valid_req = obj { + "request_uid" to HashCode.rand() + "amount" to "KUDOS:55" + "exchange_base_url" to "http://exchange.example.com/" + "wtid" to ShortHashCode.rand() + "credit_account" to merchantPayto.canonical + } + + 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) + + // Giving debt allowance and checking the OK case. + setMaxDebt("exchange", "KUDOS:1000") + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(valid_req) + }.assertOk() + + // check idempotency + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(valid_req) + }.assertOk() + + // Trigger conflict due to reused request_uid + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(valid_req) { + "wtid" to ShortHashCode.rand() + "exchange_base_url" to "http://different-exchange.example.com/" + } + }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) + + // Currency mismatch + client.postA("/accounts/exchange/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") { + json(valid_req) { + "wtid" to "I love chocolate" + } + }.assertBadRequest() + + // Bad BASE32 len wtid + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(valid_req) { + "wtid" to randBase32Crockford(31) + } + }.assertBadRequest() + + // Bad BASE32 request_uid + client.postA("/accounts/exchange/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") { + json(valid_req) { + "request_uid" to randBase32Crockford(65) + } + }.assertBadRequest() + }*/ + /* + /** + * Testing the /history/incoming call from the TWG API. + */ + @Test + fun historyIncoming() = serverSetup { + // Give Foo reasonable debt allowance: + setMaxDebt("merchant", "KUDOS:1000") + authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/incoming") + historyRoutine<IncomingHistory>( + 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") + } + ), + ignored = listOf( + { + // Ignore malformed incoming transaction + tx("merchant", "KUDOS:10", "exchange", "ignored") + }, + { + // Ignore malformed outgoing transaction + tx("exchange", "KUDOS:10", "merchant", "ignored") + } + ) + ) + } + + + /** + * Testing the /history/outgoing call from the TWG API. + */ + @Test + fun historyOutgoing() = serverSetup { + setMaxDebt("exchange", "KUDOS:1000000") + authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/outgoing") + historyRoutine<OutgoingHistory>( + 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") + } + ), + 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") + } + ) + ) + }*/ + + // Testing the /admin/add-incoming call from the TWG API. + @Test + fun addIncoming() = serverSetup { _ -> + val valid_req = obj { + "amount" to "CHF:44" + "reserve_pub" to EddsaPublicKey.rand() + "debit_account" to grothoffPayto + } + + //authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req, requireAdmin = true) + + // Check OK + client.post("/taler-wire-gateway/admin/add-incoming") { + json(valid_req) + }.assertOk() + + // Trigger conflict due to reused reserve_pub + client.post("/taler-wire-gateway/admin/add-incoming") { + json(valid_req) + }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) + + // Currency mismatch + client.post("/taler-wire-gateway/admin/add-incoming") { + json(valid_req) { "amount" to "EUR:33" } + }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) + + // Bad BASE32 reserve_pub + client.post("/taler-wire-gateway/admin/add-incoming") { + json(valid_req) { + "reserve_pub" to "I love chocolate" + } + }.assertBadRequest() + + // Bad BASE32 len reserve_pub + client.post("/taler-wire-gateway/admin/add-incoming") { + json(valid_req) { + "reserve_pub" to Base32Crockford.encode(ByteArray(31).rand()) + } + }.assertBadRequest() + } +} +\ No newline at end of file diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -20,6 +20,9 @@ import io.ktor.client.* import io.ktor.client.engine.mock.* import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* import kotlinx.coroutines.runBlocking import tech.libeufin.common.TalerAmount import tech.libeufin.common.db.dbInit @@ -53,6 +56,20 @@ fun setup( } } +fun serverSetup( + conf: String = "test.conf", + lambda: suspend ApplicationTestBuilder.(Database) -> Unit +) = setup { db, cfg -> + testApplication { + application { + nexusApi(db, cfg) + } + lambda(db) + } +} + +val grothoffPayto = "payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans" + val clientKeys = generateNewKeys() // Gets an HTTP client whose requests are going to be served by 'handler'.