commit 7a364baa832a0373d2870ab9254a776f2076e77c parent e2ccdba1cfc4588e7fee12242478d6f2d812bf48 Author: Antoine A <> Date: Tue, 31 Mar 2026 11:43:57 +0200 common: rename Taler Wire Transfer Gateway to Taler Prepared Transfer Diffstat:
15 files changed, 465 insertions(+), 503 deletions(-)
diff --git a/contrib/nexus.conf b/contrib/nexus.conf @@ -106,7 +106,7 @@ BIND_TO = 0.0.0.0 # UNIXPATH_MODE = 660 [nexus-httpd-wire-gateway-api] -# Whether to serve the Wire Gateway API +# Whether to serve the Wire Gateway API and the Prepared Transfer API ENABLED = NO # Authentication scheme, this can either can be basic, bearer or none. @@ -121,22 +121,6 @@ AUTH_METHOD = bearer # Token for bearer authentication scheme TOKEN = -[nexus-httpd-wire-transfer-gateway-api] -# Whether to serve the Wire Transfer Gateway API -ENABLED = NO - -# Authentication scheme, this can either can be basic, bearer or none. -AUTH_METHOD = none - -# User name for basic authentication scheme -# USERNAME = - -# Password for basic authentication scheme -# PASSWORD = - -# Token for bearer authentication scheme -# TOKEN = - [nexus-httpd-revenue-api] # Whether to serve the Revenue API ENABLED = NO diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -39,7 +39,7 @@ fun Application.corebankWebApp(db: Database, cfg: BankConfig) = talerApi(LoggerF conversionApi(db, cfg) bankIntegrationApi(db, cfg) wireGatewayApi(db, cfg) - wireTransferApi(db, cfg) + preparedTransferApi(db, cfg) revenueApi(db, cfg) observabilityApi(db, cfg) cfg.spaPath?.let { diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/PreparedTransferApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/PreparedTransferApi.kt @@ -0,0 +1,111 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 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.bank.api + +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import tech.libeufin.bank.* +import tech.libeufin.bank.auth.pathUsername +import tech.libeufin.bank.db.Database +import tech.libeufin.bank.db.TransferDAO.RegistrationResult +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import java.time.Instant +import java.time.Duration + +fun Routing.preparedTransferApi(db: Database, cfg: BankConfig) { + get("/accounts/{USERNAME}/taler-prepared-transfer/config") { + call.respond( + PreparedTransferConfig( + currency = cfg.regionalCurrency, + supported_formats = listOf(SubjectFormat.SIMPLE) + ) + ) + } + post("/accounts/{USERNAME}/taler-prepared-transfer/registration") { + val username = call.pathUsername + val req = call.receive<SubjectRequest>(); + cfg.checkRegionalCurrency(req.credit_amount) + + if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_sig, req.authorization_pub)) + throw conflict( + "invalid signature", + TalerErrorCode.BANK_BAD_SIGNATURE + ) + + when (val result = db.transfer.register( + username, + req.type, + req.account_pub, + req.authorization_pub, + req.authorization_sig, + req.recurrent, + req.credit_amount, + Instant.now() + )) { + RegistrationResult.UnknownAccount -> throw unknownAccount(username) + RegistrationResult.NotExchange -> throw notExchange(username) + RegistrationResult.ReservePubReuse -> throw conflict( + "reserve_pub used already", + TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + is RegistrationResult.Success -> { + val subjects = mutableListOf<TransferSubject>() + if (result.uuid != null) + subjects.add(TransferSubject.Uri(cfg.talerWithdrawUri(result.uuid), req.credit_amount)) + subjects.add(TransferSubject.Simple(fmtIncomingSubject(IncomingType.map, req.authorization_pub), req.credit_amount)) + call.respond( + SubjectResult( + subjects, + TalerTimestamp.never() + ) + ) + } + } + } + delete("/accounts/{USERNAME}/taler-prepared-transfer/registration") { + val req = call.receive<Unregistration>(); + + val timestamp = Instant.parse(req.timestamp) + + if (timestamp.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) + throw conflict( + "timestamp too old", + TalerErrorCode.BANK_OLD_TIMESTAMP + ) + + if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_sig, req.authorization_pub)) + throw conflict( + "invalid signature", + TalerErrorCode.BANK_BAD_SIGNATURE + ) + + if (db.transfer.unregister(req.authorization_pub, Instant.now())) { + call.respond(HttpStatusCode.NoContent) + } else { + throw notFound( + "Prepared transfer '${req.authorization_pub}' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + } + } +} diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireTransferApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireTransferApi.kt @@ -1,114 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2026 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.bank.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.bank.* -import tech.libeufin.bank.auth.pathUsername -import tech.libeufin.bank.db.Database -import tech.libeufin.bank.db.TransferDAO -import tech.libeufin.bank.db.TransferDAO.RegistrationResult -import tech.libeufin.common.* -import tech.libeufin.common.crypto.CryptoUtil -import java.time.Instant -import java.time.Duration - -fun Routing.wireTransferApi(db: Database, cfg: BankConfig) { - get("/accounts/{USERNAME}/taler-wire-transfer-gateway/config") { - call.respond( - WireTransferConfig( - currency = cfg.regionalCurrency, - supported_formats = listOf(SubjectFormat.SIMPLE) - ) - ) - } - post("/accounts/{USERNAME}/taler-wire-transfer-gateway/registration") { - val username = call.pathUsername - val req = call.receive<SubjectRequest>(); - cfg.checkRegionalCurrency(req.credit_amount) - - if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_sig, req.authorization_pub)) - throw conflict( - "invalid signature", - TalerErrorCode.BANK_BAD_SIGNATURE - ) - - when (val result = db.transfer.register( - username, - req.type, - req.account_pub, - req.authorization_pub, - req.authorization_sig, - req.recurrent, - req.credit_amount, - Instant.now() - )) { - RegistrationResult.UnknownAccount -> throw unknownAccount(username) - RegistrationResult.NotExchange -> throw notExchange(username) - RegistrationResult.ReservePubReuse -> throw conflict( - "reserve_pub used already", - TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT - ) - is RegistrationResult.Success -> { - val subjects = mutableListOf<TransferSubject>() - if (result.uuid != null) - subjects.add(TransferSubject.Uri(cfg.talerWithdrawUri(result.uuid), req.credit_amount)) - subjects.add(TransferSubject.Simple(fmtIncomingSubject(IncomingType.map, req.authorization_pub), req.credit_amount)) - call.respond( - SubjectResult( - subjects, - TalerTimestamp.never() - ) - ) - } - } - } - delete("/accounts/{USERNAME}/taler-wire-transfer-gateway/registration") { - val req = call.receive<Unregistration>(); - - val timestamp = Instant.parse(req.timestamp) - - if (timestamp.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) - throw conflict( - "timestamp too old", - TalerErrorCode.BANK_OLD_TIMESTAMP - ) - - if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_sig, req.authorization_pub)) - throw conflict( - "invalid signature", - TalerErrorCode.BANK_BAD_SIGNATURE - ) - - if (db.transfer.unregister(req.authorization_pub, Instant.now())) { - call.respond(HttpStatusCode.NoContent) - } else { - throw notFound( - "Prepared transfer '${req.authorization_pub}' not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - } - } -} diff --git a/libeufin-bank/src/test/kotlin/WireTransferApiTest.kt b/libeufin-bank/src/test/kotlin/WireTransferApiTest.kt @@ -18,24 +18,21 @@ */ import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.server.testing.* import org.junit.Test import tech.libeufin.common.* import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.common.test.* import java.time.Instant -import java.util.UUID import kotlin.test.* class WireTransferApiTest { - // GET /accounts/{USERNAME}/taler-wire-transfer-gateway/config + // GET /accounts/{USERNAME}/taler-prepared-transfer/config @Test fun config() = bankSetup { - client.get("/accounts/merchant/taler-wire-transfer-gateway/config").assertOkJson<WireTransferConfig>() + client.get("/accounts/merchant/taler-prepared-transfer/config").assertOkJson<PreparedTransferConfig>() } - // POST /accounts/{USERNAME}/taler-wire-transfer-gateway/registration + // POST /accounts/{USERNAME}/taler-prepared-transfer/registration @Test fun registration() = bankSetup { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() @@ -53,7 +50,7 @@ class WireTransferApiTest { val simpleSubject = TransferSubject.Simple("Taler MAP:$pub", amount) // Valid - val subjects = client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + val subjects = client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) }.assertOkJson<SubjectResult> { assertEquals(it.subjects[1], simpleSubject) @@ -61,14 +58,14 @@ class WireTransferApiTest { }.subjects // Idempotent - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, subjects) } // KYC has a different withdrawal uri - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "type" to "kyc" } @@ -79,7 +76,7 @@ class WireTransferApiTest { } // Recurrent only has simple subject - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "recurrent" to true } @@ -88,19 +85,19 @@ class WireTransferApiTest { } // Bad signature - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "authorization_sig" to EddsaSignature.rand() } }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) // Not exchange - client.post("/accounts/merchant/taler-wire-transfer-gateway/registration") { + client.post("/accounts/merchant/taler-prepared-transfer/registration") { json(valid_req) }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE) // Unknown account - client.post("/accounts/unknown/taler-wire-transfer-gateway/registration") { + client.post("/accounts/unknown/taler-prepared-transfer/registration") { json(valid_req) }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) @@ -108,7 +105,7 @@ class WireTransferApiTest { assertBalance("exchange", "+KUDOS:0") // Non recurrent accept on then bounce - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "type" to "reserve" } @@ -122,7 +119,7 @@ class WireTransferApiTest { } // Withdrawal is aborted on completion - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "type" to "kyc" } @@ -139,7 +136,7 @@ class WireTransferApiTest { // Recurrent accept one and delay others val newKey = EddsaPublicKey.randEdsaKey() - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "account_pub" to newKey "authorization_sig" to CryptoUtil.eddsaSign(newKey.raw, priv) @@ -156,7 +153,7 @@ class WireTransferApiTest { // Complete pending on recurrent update val kycKey = EddsaPublicKey.randEdsaKey() - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "type" to "kyc" "account_pub" to kycKey @@ -164,7 +161,7 @@ class WireTransferApiTest { "recurrent" to true } }.assertOkJson<SubjectResult>() - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "type" to "reserve" "account_pub" to kycKey @@ -181,7 +178,7 @@ class WireTransferApiTest { assertBalance("exchange", "+KUDOS:8") // Switching to non recurrent cancel pending - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "type" to "kyc" "account_pub" to kycKey @@ -196,7 +193,7 @@ class WireTransferApiTest { val testKey = EddsaPublicKey.randEdsaKey() val testSig = CryptoUtil.eddsaSign(testKey.raw, testPriv) val qr = subjectFmtQrBill(testAuth) - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "account_pub" to testKey "authorization_pub" to testAuth @@ -207,7 +204,7 @@ class WireTransferApiTest { tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth") tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth") tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth") - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "type" to "kyc" "account_pub" to testKey @@ -218,7 +215,7 @@ class WireTransferApiTest { }.assertOkJson<SubjectResult>() val otherPub = EddsaPublicKey.randEdsaKey() val otherSig = CryptoUtil.eddsaSign(otherPub.raw, testPriv) - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "type" to "reserve" "account_pub" to otherPub @@ -247,7 +244,7 @@ class WireTransferApiTest { )) } - // DELETE /accounts/{USERNAME}/taler-wire-transfer-gateway/registration + // DELETE /accounts/{USERNAME}/taler-prepared-transfer/registration @Test fun unregistration() = bankSetup { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() @@ -262,7 +259,7 @@ class WireTransferApiTest { } // Unknown - client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.delete("/accounts/exchange/taler-prepared-transfer/registration") { val now = Instant.now().toString() json { "timestamp" to now @@ -272,10 +269,10 @@ class WireTransferApiTest { }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Know - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) }.assertOkJson<SubjectResult>() - client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.delete("/accounts/exchange/taler-prepared-transfer/registration") { val now = Instant.now().toString() json { "timestamp" to now @@ -285,7 +282,7 @@ class WireTransferApiTest { }.assertNoContent() // Idempotent - client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.delete("/accounts/exchange/taler-prepared-transfer/registration") { val now = Instant.now().toString() json { "timestamp" to now @@ -295,7 +292,7 @@ class WireTransferApiTest { }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Bad signature - client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.delete("/accounts/exchange/taler-prepared-transfer/registration") { val now = Instant.now().toString() json { "timestamp" to now @@ -305,7 +302,7 @@ class WireTransferApiTest { }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) // Old timestamp - client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.delete("/accounts/exchange/taler-prepared-transfer/registration") { val now = Instant.now().minusSeconds(1000000).toString() json { "timestamp" to now @@ -323,7 +320,7 @@ class WireTransferApiTest { // Pending bounced after deletion val newKey = EddsaPublicKey.randEdsaKey() - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) { "account_pub" to newKey "authorization_sig" to CryptoUtil.eddsaSign(newKey.raw, priv) @@ -335,7 +332,7 @@ class WireTransferApiTest { tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending assertBalance("customer", "-KUDOS:3") assertBalance("exchange", "+KUDOS:3") - client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.delete("/accounts/exchange/taler-prepared-transfer/registration") { val now = Instant.now().toString() json { "timestamp" to now diff --git a/libeufin-bank/src/test/kotlin/bench.kt b/libeufin-bank/src/test/kotlin/bench.kt @@ -397,10 +397,10 @@ class Bench { "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) "recurrent" to false } - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) }.assertOkJson<SubjectResult>() - client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.post("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) }.assertOkJson<SubjectResult>() } @@ -412,10 +412,10 @@ class Bench { "authorization_pub" to pub "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } - client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.delete("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) }.assertNoContent() - client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { + client.delete("/accounts/exchange/taler-prepared-transfer/registration") { json(valid_req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } diff --git a/libeufin-common/src/main/kotlin/TalerMessage.kt b/libeufin-common/src/main/kotlin/TalerMessage.kt @@ -203,18 +203,18 @@ data class OutgoingTransaction( @Serializable class AccountInfo() -/** Response GET /taler-wire-transfer-gateway/config */ +/** Response GET /taler-prepared-transfer/config */ @Serializable -data class WireTransferConfig( +data class PreparedTransferConfig( val currency: String, val supported_formats: List<SubjectFormat> ) { - val name: String = "taler-wire-transfer-gateway" + val name: String = "taler-prepared-transfer" val version: String = WIRE_TRANSFER_API_VERSION } -/** Inner response GET /taler-wire-transfer-gateway/registration */ +/** Inner response GET /taler-prepared-transfer/registration */ @Serializable sealed interface TransferSubject { @Serializable diff --git a/libeufin-nexus/conf/test.conf b/libeufin-nexus/conf/test.conf @@ -19,10 +19,6 @@ ENABLED = YES AUTH_METHOD = bearer TOKEN = secret-token -[nexus-httpd-wire-transfer-gateway-api] -ENABLED = YES -AUTH_METHOD = none - [nexus-httpd-revenue-api] ENABLED = YES AUTH_METHOD = bearer diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -147,7 +147,6 @@ class NexusConfig internal constructor (val cfg: TalerConfig) { ) val wireGatewayApiCfg = cfg.section("nexus-httpd-wire-gateway-api").apiConf() - val wireTransferApiCfg = cfg.section("nexus-httpd-wire-transfer-gateway-api").apiConf() val revenueApiCfg = cfg.section("nexus-httpd-revenue-api").apiConf() val observabilityApiCfg = cfg.section("nexus-httpd-observability-api").apiConf() } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -32,7 +32,7 @@ import kotlinx.serialization.Contextual import tech.libeufin.common.api.talerApi import tech.libeufin.common.setupSecurityProperties import tech.libeufin.nexus.api.revenueApi -import tech.libeufin.nexus.api.wireTransferApi +import tech.libeufin.nexus.api.preparedTransferAPI import tech.libeufin.nexus.api.wireGatewayApi import tech.libeufin.nexus.api.observabilityApi import tech.libeufin.nexus.cli.LibeufinNexus @@ -49,7 +49,7 @@ data class IbanAccountMetadata( fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(LoggerFactory.getLogger("libeufin-nexus-api")) { wireGatewayApi(db, cfg) - wireTransferApi(db, cfg) + preparedTransferAPI(db, cfg) revenueApi(db, cfg) observabilityApi(db, cfg) } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/PreparedTransferApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/PreparedTransferApi.kt @@ -0,0 +1,109 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 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.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.nexus.NexusConfig +import tech.libeufin.nexus.db.Database +import tech.libeufin.nexus.db.TransferDAO.RegistrationResult +import java.time.Instant +import java.time.Duration + +fun Routing.preparedTransferAPI(db: Database, cfg: NexusConfig) = conditional(cfg.wireGatewayApiCfg) { + get("/taler-prepared-transfer/config") { + call.respond( + PreparedTransferConfig( + currency = cfg.currency, + supported_formats = listOf(SubjectFormat.SIMPLE, SubjectFormat.CH_QR_BILL) + ) + ) + } + post("/taler-prepared-transfer/registration") { + val req = call.receive<SubjectRequest>(); + + if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_sig, req.authorization_pub)) + throw conflict( + "invalid signature", + TalerErrorCode.BANK_BAD_SIGNATURE + ) + + val reference = subjectFmtQrBill(req.authorization_pub) + + when (val result = db.transfer.register( + req.type, + req.account_pub, + req.authorization_pub, + req.authorization_sig, + req.recurrent, + reference, + Instant.now() + )) { + RegistrationResult.ReservePubReuse -> throw conflict( + "reserve_pub used already", + TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + RegistrationResult.SubjectReuse -> throw conflict( + "subject derivation used already", + TalerErrorCode.BANK_DERIVATION_REUSE + ) + RegistrationResult.Success -> { + call.respond( + SubjectResult( + listOf( + TransferSubject.QrBill(reference, req.credit_amount), + TransferSubject.Simple(fmtIncomingSubject(IncomingType.map, req.authorization_pub), req.credit_amount) + ), + TalerTimestamp.never() + ) + ) + } + } + } + delete("/taler-prepared-transfer/registration") { + val req = call.receive<Unregistration>(); + + val timestamp = Instant.parse(req.timestamp) + + if (timestamp.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) + throw conflict( + "timestamp too old", + TalerErrorCode.BANK_OLD_TIMESTAMP + ) + + if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_sig, req.authorization_pub)) + throw conflict( + "invalid signature", + TalerErrorCode.BANK_BAD_SIGNATURE + ) + if (db.transfer.unregister(req.authorization_pub, Instant.now())) { + call.respond(HttpStatusCode.NoContent) + } else { + throw notFound( + "Prepared transfer '${req.authorization_pub}' not found", + TalerErrorCode.BANK_TRANSACTION_NOT_FOUND + ) + } + } +} diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireTransferApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireTransferApi.kt @@ -1,117 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2026 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.common.crypto.CryptoUtil -import tech.libeufin.nexus.NexusConfig -import tech.libeufin.nexus.checkCurrency -import tech.libeufin.nexus.db.Database -import tech.libeufin.nexus.db.ExchangeDAO -import tech.libeufin.nexus.db.ExchangeDAO.TransferResult -import tech.libeufin.nexus.db.TransferDAO.RegistrationResult -import tech.libeufin.nexus.db.PaymentDAO.IncomingRegistrationResult -import tech.libeufin.nexus.iso20022.* -import tech.libeufin.ebics.randEbicsId -import java.time.Instant -import java.time.Duration - -fun Routing.wireTransferApi(db: Database, cfg: NexusConfig) = conditional(cfg.wireTransferApiCfg) { - get("/taler-wire-transfer-gateway/config") { - call.respond( - WireTransferConfig( - currency = cfg.currency, - supported_formats = listOf(SubjectFormat.SIMPLE, SubjectFormat.CH_QR_BILL) - ) - ) - } - post("/taler-wire-transfer-gateway/registration") { - val req = call.receive<SubjectRequest>(); - - if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_sig, req.authorization_pub)) - throw conflict( - "invalid signature", - TalerErrorCode.BANK_BAD_SIGNATURE - ) - - val reference = subjectFmtQrBill(req.authorization_pub) - - when (val result = db.transfer.register( - req.type, - req.account_pub, - req.authorization_pub, - req.authorization_sig, - req.recurrent, - reference, - Instant.now() - )) { - RegistrationResult.ReservePubReuse -> throw conflict( - "reserve_pub used already", - TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT - ) - RegistrationResult.SubjectReuse -> throw conflict( - "subject derivation used already", - TalerErrorCode.BANK_DERIVATION_REUSE - ) - RegistrationResult.Success -> { - call.respond( - SubjectResult( - listOf( - TransferSubject.QrBill(reference, req.credit_amount), - TransferSubject.Simple(fmtIncomingSubject(IncomingType.map, req.authorization_pub), req.credit_amount) - ), - TalerTimestamp.never() - ) - ) - } - } - } - delete("/taler-wire-transfer-gateway/registration") { - val req = call.receive<Unregistration>(); - - val timestamp = Instant.parse(req.timestamp) - - if (timestamp.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) - throw conflict( - "timestamp too old", - TalerErrorCode.BANK_OLD_TIMESTAMP - ) - - if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_sig, req.authorization_pub)) - throw conflict( - "invalid signature", - TalerErrorCode.BANK_BAD_SIGNATURE - ) - if (db.transfer.unregister(req.authorization_pub, Instant.now())) { - call.respond(HttpStatusCode.NoContent) - } else { - throw notFound( - "Prepared transfer '${req.authorization_pub}' not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - } - } -} diff --git a/libeufin-nexus/src/test/kotlin/PreparedTransferApiTest.kt b/libeufin-nexus/src/test/kotlin/PreparedTransferApiTest.kt @@ -0,0 +1,200 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 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.request.* +import org.junit.Test +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.nexus.* +import tech.libeufin.nexus.cli.* +import java.time.Instant +import kotlin.test.* + +class PreparedTransferApiTest { + // GET /taler-prepared-transfer/config + @Test + fun config() = serverSetup { + client.get("/taler-prepared-transfer/config").assertOkJson<PreparedTransferConfig>() + } + + // POST /taler-prepared-transfer/registration + @Test + fun registration() = serverSetup { db -> + val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() + val amount = TalerAmount("KUDOS:55") + val valid_req = obj { + "credit_amount" to amount + "type" to "reserve" + "alg" to "EdDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + + val subjects = listOf( + TransferSubject.QrBill(subjectFmtQrBill(pub), amount), + TransferSubject.Simple("Taler MAP:$pub", amount), + ) + + // Valid + client.post("/taler-prepared-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult> { + assertEquals(it.subjects, subjects) + } + + // Idempotent + client.post("/taler-prepared-transfer/registration") { + json(valid_req) + }.assertOkJson<SubjectResult> { + assertEquals(it.subjects, subjects) + } + + // Bad signature + client.post("/taler-prepared-transfer/registration") { + json(valid_req) { + "authorization_sig" to EddsaSignature.rand() + } + }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + + // Check authorization field in incoming history + val (testPriv, testAuth) = EddsaPublicKey.randEdsaKeyPair() + val testKey = EddsaPublicKey.randEdsaKey() + val testSig = CryptoUtil.eddsaSign(testKey.raw, testPriv) + val qr = subjectFmtQrBill(testAuth) + client.post("/taler-prepared-transfer/registration") { + json(valid_req) { + "account_pub" to testKey + "authorization_pub" to testAuth + "authorization_sig" to testSig + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + val cfg = NexusIngestConfig.default(AccountType.exchange) + registerIncomingPayment(db, cfg, genInPay(qr)) + registerIncomingPayment(db, cfg, genInPay(qr)) + registerIncomingPayment(db, cfg, genInPay(qr)) + client.post("/taler-prepared-transfer/registration") { + json(valid_req) { + "type" to "kyc" + "account_pub" to testKey + "authorization_pub" to testAuth + "authorization_sig" to testSig + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + val otherPub = EddsaPublicKey.randEdsaKey() + val otherSig = CryptoUtil.eddsaSign(otherPub.raw, testPriv) + client.post("/taler-prepared-transfer/registration") { + json(valid_req) { + "type" to "reserve" + "account_pub" to otherPub + "authorization_pub" to testAuth + "authorization_sig" to otherSig + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + val lastPub = EddsaPublicKey.randEdsaKey() + talerableIn(db, reserve_pub=lastPub) + talerableKycIn(db, account_pub=lastPub) + val history = client.getA("/taler-wire-gateway/history/incoming?limit=-5") + .assertOkJson<IncomingHistory>().incoming_transactions.map { + when (it) { + is IncomingKycAuthTransaction -> Triple(it.account_pub, it.authorization_pub, it.authorization_sig) + is IncomingReserveTransaction -> Triple(it.reserve_pub, it.authorization_pub, it.authorization_sig) + else -> throw UnsupportedOperationException() + } + } + assertContentEquals(history, listOf( + Triple(lastPub, null, null), + Triple(lastPub, null, null), + Triple(otherPub, testAuth, otherSig), + Triple(testKey, testAuth, testSig), + Triple(testKey, testAuth, testSig) + )) + } + + // DELETE /taler-prepared-transfer/registration + @Test + fun unregistration() = serverSetup { + val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() + + // Unknown + client.delete("/taler-prepared-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + + // Know + client.post("/taler-prepared-transfer/registration") { + json { + "credit_amount" to "KUDOS:55" + "type" to "reserve" + "alg" to "EdDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + }.assertOkJson<SubjectResult>() + client.delete("/taler-prepared-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNoContent() + + // Idempotent + client.delete("/taler-prepared-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + + // Bad signature + client.delete("/taler-prepared-transfer/registration") { + val now = Instant.now().toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_sig" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) + } + }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + + // Old timestamp + client.delete("/taler-prepared-transfer/registration") { + val now = Instant.now().minusSeconds(1000000).toString() + json { + "timestamp" to now + "authorization_pub" to pub + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + } + }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP) + } +} +\ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/WireTransferApiTest.kt b/libeufin-nexus/src/test/kotlin/WireTransferApiTest.kt @@ -1,203 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2026 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.request.* -import io.ktor.http.* -import io.ktor.server.testing.* -import org.junit.Test -import tech.libeufin.common.* -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.nexus.* -import tech.libeufin.nexus.cli.* -import tech.libeufin.ebics.randEbicsId -import java.time.Instant -import kotlin.test.* - -class WireTransferApiTest { - // GET /taler-wire-transfer-gateway/config - @Test - fun config() = serverSetup { - client.get("/taler-wire-transfer-gateway/config").assertOkJson<WireTransferConfig>() - } - - // POST /taler-wire-transfer-gateway/registration - @Test - fun registration() = serverSetup { db -> - val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() - val amount = TalerAmount("KUDOS:55") - val valid_req = obj { - "credit_amount" to amount - "type" to "reserve" - "alg" to "EdDSA" - "account_pub" to pub - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) - "recurrent" to false - } - - val subjects = listOf( - TransferSubject.QrBill(subjectFmtQrBill(pub), amount), - TransferSubject.Simple("Taler MAP:$pub", amount), - ) - - // Valid - client.post("/taler-wire-transfer-gateway/registration") { - json(valid_req) - }.assertOkJson<SubjectResult> { - assertEquals(it.subjects, subjects) - } - - // Idempotent - client.post("/taler-wire-transfer-gateway/registration") { - json(valid_req) - }.assertOkJson<SubjectResult> { - assertEquals(it.subjects, subjects) - } - - // Bad signature - client.post("/taler-wire-transfer-gateway/registration") { - json(valid_req) { - "authorization_sig" to EddsaSignature.rand() - } - }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) - - // Check authorization field in incoming history - val (testPriv, testAuth) = EddsaPublicKey.randEdsaKeyPair() - val testKey = EddsaPublicKey.randEdsaKey() - val testSig = CryptoUtil.eddsaSign(testKey.raw, testPriv) - val qr = subjectFmtQrBill(testAuth) - client.post("/taler-wire-transfer-gateway/registration") { - json(valid_req) { - "account_pub" to testKey - "authorization_pub" to testAuth - "authorization_sig" to testSig - "recurrent" to true - } - }.assertOkJson<SubjectResult>() - val cfg = NexusIngestConfig.default(AccountType.exchange) - registerIncomingPayment(db, cfg, genInPay(qr)) - registerIncomingPayment(db, cfg, genInPay(qr)) - registerIncomingPayment(db, cfg, genInPay(qr)) - client.post("/taler-wire-transfer-gateway/registration") { - json(valid_req) { - "type" to "kyc" - "account_pub" to testKey - "authorization_pub" to testAuth - "authorization_sig" to testSig - "recurrent" to true - } - }.assertOkJson<SubjectResult>() - val otherPub = EddsaPublicKey.randEdsaKey() - val otherSig = CryptoUtil.eddsaSign(otherPub.raw, testPriv) - client.post("/taler-wire-transfer-gateway/registration") { - json(valid_req) { - "type" to "reserve" - "account_pub" to otherPub - "authorization_pub" to testAuth - "authorization_sig" to otherSig - "recurrent" to true - } - }.assertOkJson<SubjectResult>() - val lastPub = EddsaPublicKey.randEdsaKey() - talerableIn(db, reserve_pub=lastPub) - talerableKycIn(db, account_pub=lastPub) - val history = client.getA("/taler-wire-gateway/history/incoming?limit=-5") - .assertOkJson<IncomingHistory>().incoming_transactions.map { - when (it) { - is IncomingKycAuthTransaction -> Triple(it.account_pub, it.authorization_pub, it.authorization_sig) - is IncomingReserveTransaction -> Triple(it.reserve_pub, it.authorization_pub, it.authorization_sig) - else -> throw UnsupportedOperationException() - } - } - assertContentEquals(history, listOf( - Triple(lastPub, null, null), - Triple(lastPub, null, null), - Triple(otherPub, testAuth, otherSig), - Triple(testKey, testAuth, testSig), - Triple(testKey, testAuth, testSig) - )) - } - - // DELETE /taler-wire-transfer-gateway/registration - @Test - fun unregistration() = serverSetup { - val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() - - // Unknown - client.delete("/taler-wire-transfer-gateway/registration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } - }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - - // Know - client.post("/taler-wire-transfer-gateway/registration") { - json { - "credit_amount" to "KUDOS:55" - "type" to "reserve" - "alg" to "EdDSA" - "account_pub" to pub - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) - "recurrent" to false - } - }.assertOkJson<SubjectResult>() - client.delete("/taler-wire-transfer-gateway/registration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } - }.assertNoContent() - - // Idempotent - client.delete("/taler-wire-transfer-gateway/registration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } - }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - - // Bad signature - client.delete("/taler-wire-transfer-gateway/registration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) - } - }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) - - // Old timestamp - client.delete("/taler-wire-transfer-gateway/registration") { - val now = Instant.now().minusSeconds(1000000).toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } - }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP) - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/bench.kt b/libeufin-nexus/src/test/kotlin/bench.kt @@ -205,10 +205,10 @@ class Bench { "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) "recurrent" to false } - client.post("/taler-wire-transfer-gateway/registration") { + client.post("/taler-prepared-transfer/registration") { json(valid_req) }.assertOkJson<SubjectResult>() - client.post("/taler-wire-transfer-gateway/registration") { + client.post("/taler-prepared-transfer/registration") { json(valid_req) }.assertOkJson<SubjectResult>() } @@ -220,10 +220,10 @@ class Bench { "authorization_pub" to pub "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } - client.delete("/taler-wire-transfer-gateway/registration") { + client.delete("/taler-prepared-transfer/registration") { json(valid_req) }.assertNoContent() - client.delete("/taler-wire-transfer-gateway/registration") { + client.delete("/taler-prepared-transfer/registration") { json(valid_req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) }