commit 449d1bcf56972717f59d1ab3082f1adc28c341b4
parent 96f44f2a12d4e5c71d043a9bbc737ca20a6d4d0b
Author: Antoine A <>
Date: Thu, 23 Nov 2023 00:11:28 +0000
bank and nexus integration draft
Diffstat:
15 files changed, 400 insertions(+), 48 deletions(-)
diff --git a/Makefile b/Makefile
@@ -41,6 +41,7 @@ install-bank-files:
install contrib/libeufin-bank.conf $(bank_config_dir)/
install contrib/currencies.conf $(bank_config_dir)/
install -D database-versioning/libeufin-bank*.sql -t $(bank_sql_dir)
+ install -D database-versioning/libeufin-conversion.sql -t $(bank_sql_dir)
install -D database-versioning/versioning.sql -t $(bank_sql_dir)
.PHONY: install-bank
diff --git a/bank/conf/test.conf b/bank/conf/test.conf
@@ -21,3 +21,9 @@ cashin_rounding_mode = nearest
cashout_ratio = 1.25
cashout_fee = EUR:0.003
cashout_min_amount = KUDOS:0.1
+
+[nexus-postgres]
+CONFIG = postgres:///libeufincheck
+
+[libeufin-nexusdb-postgres]
+SQL_DIR = $DATADIR/sql/
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -45,6 +45,7 @@ import java.time.Duration
import java.util.zip.DataFormatException
import java.util.zip.Inflater
import java.sql.SQLException
+import java.io.File
import kotlin.system.exitProcess
import kotlinx.coroutines.*
import kotlinx.serialization.ExperimentalSerializationApi
@@ -56,9 +57,7 @@ import org.slf4j.LoggerFactory
import org.slf4j.event.Level
import org.postgresql.util.PSQLState
import tech.libeufin.bank.AccountDAO.*
-import tech.libeufin.util.getVersion
-import tech.libeufin.util.initializeDatabaseTables
-import tech.libeufin.util.resetDatabaseTables
+import tech.libeufin.util.*
// GLOBALS
private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main")
@@ -188,6 +187,7 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) {
)
}
exception<SQLException> { call, cause ->
+ cause.printStackTrace()
val err = when (cause.sqlState) {
PSQLState.SERIALIZATION_FAILURE.state -> libeufinError(
HttpStatusCode.InternalServerError,
@@ -321,6 +321,35 @@ class BankDbInit : CliktCommand("Initialize the libeufin-bank database", name =
}
}
+class ConversionSetupCmd : CliktCommand("Setup conversion support", name = "conversion-setup") {
+ private val configFile by option(
+ "--config", "-c",
+ help = "set the configuration file"
+ )
+
+ override fun run() {
+ val config = talerConfig(configFile)
+ val cfg = config.loadDbConfig()
+ val ctx = config.loadBankConfig();
+ val db = Database(cfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency)
+ runBlocking {
+ logger.info("doing DB initialization, sqldir ${cfg.sqlDir}, dbConnStr ${cfg.dbConnStr}")
+ val sqlProcedures = File("${cfg.sqlDir}/libeufin-conversion.sql")
+ if (!sqlProcedures.exists()) {
+ logger.info("Missing libeufin-conversion.sql file")
+ exitProcess(1)
+ }
+ pgDataSource(cfg.dbConnStr).pgConnection().execSQLUpdate(sqlProcedures.readText())
+
+ // Load conversion config
+ ctx.conversionInfo?.run {
+ logger.info("loading conversion config in DB")
+ db.conversion.updateConfig(this)
+ }
+ }
+ }
+}
+
class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") {
private val configFile by option(
"--config", "-c",
@@ -438,7 +467,7 @@ class BankConfigCmd : CliktCommand("Dump the configuration", name = "config") {
class LibeufinBankCommand : CliktCommand() {
init {
versionOption(getVersion())
- subcommands(ServeBank(), BankDbInit(), ChangePw(), BankConfigCmd())
+ subcommands(ServeBank(), BankDbInit(), ConversionSetupCmd(), ChangePw(), BankConfigCmd())
}
override fun run() = Unit
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.*
import net.taler.common.errorcodes.TalerErrorCode
import org.junit.Test
import tech.libeufin.bank.*
+import tech.libeufin.util.*
class BankIntegrationApiTest {
// GET /taler-integration/config
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -34,6 +34,7 @@ import net.taler.common.errorcodes.TalerErrorCode
import net.taler.wallet.crypto.Base32Crockford
import org.junit.Test
import tech.libeufin.bank.*
+import tech.libeufin.util.*
class CoreBankConfigTest {
// GET /config
diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt
@@ -28,6 +28,7 @@ import kotlinx.serialization.json.*
import net.taler.common.errorcodes.TalerErrorCode
import org.junit.Test
import tech.libeufin.bank.*
+import tech.libeufin.util.*
class WireGatewayApiTest {
// Testing the POST /transfer call from the TWG API.
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
@@ -326,32 +326,6 @@ inline suspend fun <reified B> HttpResponse.assertHistoryIds(size: Int, ids: (B)
/* ----- Body helper ----- */
-inline fun <reified B> HttpRequestBuilder.json(b: B, deflate: Boolean = false) {
- val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b);
- contentType(ContentType.Application.Json)
- if (deflate) {
- headers.set("Content-Encoding", "deflate")
- val bos = ByteArrayOutputStream()
- val ios = DeflaterOutputStream(bos)
- ios.write(json.toByteArray())
- ios.finish()
- setBody(bos.toByteArray())
- } else {
- setBody(json)
- }
-}
-
-inline fun HttpRequestBuilder.json(
- from: JsonObject = JsonObject(emptyMap()),
- deflate: Boolean = false,
- builderAction: JsonBuilder.() -> Unit
-) {
- json(obj(from, builderAction), deflate)
-}
-
-inline suspend fun <reified B> HttpResponse.json(): B =
- Json.decodeFromString(kotlinx.serialization.serializer<B>(), bodyAsText())
-
inline suspend fun <reified B> HttpResponse.assertOkJson(lambda: (B) -> Unit = {}): B {
assertOk()
val body = json<B>()
@@ -398,23 +372,6 @@ fun HttpRequestBuilder.pwAuth(username: String? = null) {
}
-/* ----- Json DSL ----- */
-
-inline fun obj(from: JsonObject = JsonObject(emptyMap()), builderAction: JsonBuilder.() -> Unit): JsonObject {
- val builder = JsonBuilder(from)
- builder.apply(builderAction)
- return JsonObject(builder.content)
-}
-
-class JsonBuilder(from: JsonObject) {
- val content: MutableMap<String, JsonElement> = from.toMutableMap()
-
- infix inline fun <reified T> String.to(v: T) {
- val json = Json.encodeToJsonElement(kotlinx.serialization.serializer<T>(), v);
- content.put(this, json)
- }
-}
-
/* ----- Random data generation ----- */
fun randBytes(lenght: Int): ByteArray {
diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt
@@ -18,6 +18,7 @@
*/
import tech.libeufin.bank.*
+import tech.libeufin.util.*
import io.ktor.client.statement.HttpResponse
import io.ktor.server.testing.ApplicationTestBuilder
import io.ktor.client.request.*
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -973,6 +973,7 @@ CREATE OR REPLACE FUNCTION cashin(
IN in_amount taler_amount,
IN in_subject TEXT,
-- Error status
+ OUT out_no_account BOOLEAN,
OUT out_too_small BOOLEAN,
OUT out_balance_insufficient BOOLEAN
)
@@ -987,6 +988,10 @@ SELECT bank_account_id
INTO wallet_account_id
FROM bank_accounts
WHERE internal_payto_uri = in_payto_uri;
+IF NOT FOUND THEN
+ out_no_account = true;
+ RETURN;
+END IF;
-- Retrieve admin account id
SELECT bank_account_id
diff --git a/database-versioning/libeufin-conversion.sql b/database-versioning/libeufin-conversion.sql
@@ -0,0 +1,78 @@
+BEGIN;
+SET search_path TO libeufin_conversion;
+
+CREATE OR REPLACE FUNCTION cashout()
+RETURNS trigger
+LANGUAGE plpgsql AS $$
+ DECLARE
+ now_date BIGINT;
+ payto_uri TEXT;
+ BEGIN
+ IF NEW.local_transaction IS NOT NULL THEN
+ SELECT transaction_date INTO now_date
+ FROM libeufin_bank.bank_account_transactions
+ WHERE bank_transaction_id = NEW.local_transaction;
+ SELECT cashout_payto INTO payto_uri
+ FROM libeufin_bank.bank_accounts
+ JOIN libeufin_bank.customers ON customer_id=owning_customer_id
+ WHERE bank_account_id=NEW.bank_account;
+ INSERT INTO libeufin_nexus.initiated_outgoing_transactions (
+ amount
+ ,wire_transfer_subject
+ ,credit_payto_uri
+ ,initiation_time
+ ,request_uid
+ ) VALUES (
+ ((NEW.amount_credit).val, (NEW.amount_credit).frac)::libeufin_nexus.taler_amount
+ ,NEW.subject
+ ,payto_uri
+ ,now_date
+ ,'TODO' -- How to generate this
+ );
+ END IF;
+ RETURN NEW;
+ END;
+$$;
+
+CREATE OR REPLACE TRIGGER cashout BEFORE INSERT OR UPDATE ON libeufin_bank.cashout_operations
+ FOR EACH ROW EXECUTE FUNCTION cashout();
+
+CREATE OR REPLACE FUNCTION cashin()
+RETURNS trigger
+LANGUAGE plpgsql AS $$
+ DECLARE
+ now_date BIGINT;
+ payto_uri TEXT;
+ local_amount libeufin_bank.taler_amount;
+ subject TEXT;
+ too_small BOOLEAN;
+ balance_insufficient BOOLEAN;
+ no_account BOOLEAN;
+ BEGIN
+ SELECT (amount).val, (amount).frac, wire_transfer_subject, execution_time, debit_payto_uri
+ INTO local_amount.val, local_amount.frac, subject, now_date, payto_uri
+ FROM libeufin_nexus.incoming_transactions
+ WHERE incoming_transaction_id = NEW.incoming_transaction_id;
+ SET search_path TO libeufin_bank;
+ SELECT out_too_small, out_balance_insufficient, out_no_account
+ INTO too_small, balance_insufficient, no_account
+ FROM libeufin_bank.cashin(now_date, payto_uri, local_amount, subject);
+ SET search_path TO libeufin_conversion;
+
+ IF no_account THEN
+ RAISE EXCEPTION 'TODO soft error bounce: unknown account';
+ END IF;
+ IF too_small THEN
+ RAISE EXCEPTION 'TODO soft error bounce: too small amount';
+ END IF;
+ IF balance_insufficient THEN
+ RAISE EXCEPTION 'TODO hard error bounce';
+ END IF;
+ RETURN NEW;
+ END;
+$$;
+
+CREATE OR REPLACE TRIGGER cashin BEFORE INSERT ON libeufin_nexus.talerable_incoming_transactions
+ FOR EACH ROW EXECUTE FUNCTION cashin();
+
+COMMIT;
+\ No newline at end of file
diff --git a/integration/build.gradle b/integration/build.gradle
@@ -0,0 +1,43 @@
+plugins {
+ id("kotlin")
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+}
+
+compileKotlin.kotlinOptions.jvmTarget = "17"
+compileTestKotlin.kotlinOptions.jvmTarget = "17"
+
+sourceSets.test.java.srcDirs = ["test"]
+
+dependencies {
+ // Core language libraries
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
+
+ implementation(project(":util"))
+ implementation(project(":bank"))
+ implementation(project(":nexus"))
+
+ implementation("org.postgresql:postgresql:42.6.0")
+ implementation("com.zaxxer:HikariCP:5.0.1")
+ implementation("com.github.ajalt.clikt:clikt:4.2.1")
+
+ implementation("io.ktor:ktor-server-core:$ktor_version")
+ implementation("io.ktor:ktor-server-call-logging:$ktor_version")
+ implementation("io.ktor:ktor-server-cors:$ktor_version")
+ implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
+ implementation("io.ktor:ktor-server-status-pages:$ktor_version")
+ implementation("io.ktor:ktor-server-netty:$ktor_version")
+ implementation("io.ktor:ktor-server-test-host:$ktor_version")
+ implementation("io.ktor:ktor-client-core:$ktor_version")
+ implementation("io.ktor:ktor-client-cio:$ktor_version")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
+ implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
+ //implementation("com.github.ajalt.clikt:clikt.testing:4.2.1")
+ testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
+
+ // UNIX domain sockets support (used to connect to PostgreSQL)
+ implementation("com.kohlschutter.junixsocket:junixsocket-core:2.8.1")
+}
+\ No newline at end of file
diff --git a/integration/test/IntegrationTest.kt b/integration/test/IntegrationTest.kt
@@ -0,0 +1,142 @@
+/*
+ * 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 org.junit.Test
+import tech.libeufin.bank.*
+import tech.libeufin.bank.Database as BankDb
+import tech.libeufin.bank.TalerAmount as BankAmount
+import tech.libeufin.nexus.*
+import tech.libeufin.nexus.Database as NexusDb
+import tech.libeufin.nexus.TalerAmount as NexusAmount
+import tech.libeufin.bank.AccountDAO.*
+import tech.libeufin.util.*
+import java.io.File
+import java.time.Instant
+import kotlinx.coroutines.runBlocking
+import com.github.ajalt.clikt.testing.test
+import com.github.ajalt.clikt.core.CliktCommand
+import kotlin.test.*
+import io.ktor.client.*
+import io.ktor.client.engine.cio.*
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.HttpStatusCode
+
+fun CliktCommand.run(cmd: String) {
+ val result = test(cmd)
+ if (result.statusCode != 0)
+ throw Exception(result.output)
+ println(result.output)
+}
+
+suspend fun HttpResponse.assertStatus(status: HttpStatusCode): HttpResponse {
+ assertEquals(status, this.status);
+ return this
+}
+
+suspend fun HttpResponse.assertCreated(): HttpResponse
+ = assertStatus(HttpStatusCode.Created)
+suspend fun HttpResponse.assertNoContent(): HttpResponse
+ = assertStatus(HttpStatusCode.NoContent)
+
+fun randBytes(lenght: Int): ByteArray {
+ val bytes = ByteArray(lenght)
+ kotlin.random.Random.nextBytes(bytes)
+ return bytes
+}
+
+class IntegrationTest {
+ @Test
+ fun db() {
+ val nexusCmd = LibeufinNexusCommand()
+ nexusCmd.run("dbinit -c ../bank/conf/test.conf -r")
+ val bankCmd = LibeufinBankCommand();
+ bankCmd.run("dbinit -c ../bank/conf/test.conf -r")
+ bankCmd.run("conversion-setup -c ../bank/conf/test.conf")
+ kotlin.concurrent.thread(isDaemon = true) {
+ bankCmd.run("serve -c ../bank/conf/test.conf")
+ }
+ runBlocking {
+ val client = HttpClient(CIO) {
+ install(HttpRequestRetry) {
+ maxRetries = 10
+ constantDelay(200, 100)
+ }
+ }
+ val nexusDb = NexusDb("postgresql:///libeufincheck")
+ val userPayTo = IbanPayTo(genIbanPaytoUri())
+ val fiatPayTo = IbanPayTo(genIbanPaytoUri())
+
+ // Create user
+ client.post("http://0.0.0.0:8080/accounts") {
+ json {
+ "username" to "customer"
+ "password" to "password"
+ "name" to "JohnSmith"
+ "internal_payto_uri" to userPayTo
+ "cashout_payto_uri" to fiatPayTo
+ "challenge_contact_data" to obj {
+ "phone" to "+99"
+ }
+ }
+ }.assertCreated()
+
+ // Cashin
+ val reservePub = randBytes(32);
+ nexusDb.incomingTalerablePaymentCreate(IncomingPayment(
+ amount = NexusAmount(44, 0, "EUR"),
+ debitPaytoUri = userPayTo.canonical,
+ wireTransferSubject = "cashin test",
+ executionTime = Instant.now(),
+ bankTransferId = "entropic"),
+ reservePub)
+ val converted = client.get("http://0.0.0.0:8080/conversion-info/cashin-rate?amount_debit=EUR:44.0")
+ .assertOkJson<ConversionResponse>().amount_credit
+ client.get("http://0.0.0.0:8080/accounts/customer/transactions") {
+ basicAuth("customer", "password")
+ }.assertOkJson<BankAccountTransactionsResponse> {
+ val tx = it.transactions[0]
+ assertEquals(userPayTo.canonical, tx.creditor_payto_uri)
+ assertEquals("cashin test", tx.subject)
+ assertEquals(converted, tx.amount)
+ }
+
+ // Cashout
+ val requestUid = randBytes(32);
+ val amount = BankAmount("KUDOS:25")
+ val convert = client.get("http://0.0.0.0:8080/conversion-info/cashout-rate?amount_debit=$amount")
+ .assertOkJson<ConversionResponse>().amount_credit;
+ client.post("http://0.0.0.0:8080/accounts/customer/cashouts") {
+ basicAuth("customer", "password")
+ json {
+ "request_uid" to ShortHashCode(requestUid)
+ "amount_debit" to amount
+ "amount_credit" to convert
+ }
+ }.assertOkJson<CashoutPending> {
+ val code = File("/tmp/tan-+99.txt").readText()
+ client.post("http://0.0.0.0:8080/accounts/customer/cashouts/${it.cashout_id}/confirm") {
+ basicAuth("customer", "password")
+ json { "tan" to code }
+ }.assertNoContent()
+ }
+ }
+ }
+}
diff --git a/settings.gradle b/settings.gradle
@@ -2,3 +2,4 @@ rootProject.name = 'libeufin'
include("bank")
include("nexus")
include("util")
+include("integration")
diff --git a/util/build.gradle b/util/build.gradle
@@ -32,6 +32,8 @@ dependencies {
implementation("io.ktor:ktor-server-test-host:$ktor_version")
// Database helper
implementation("org.postgresql:postgresql:42.6.0")
+ implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
- testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
+ implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
+
}
\ No newline at end of file
diff --git a/util/src/main/kotlin/Client.kt b/util/src/main/kotlin/Client.kt
@@ -0,0 +1,81 @@
+/*
+ * 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/>
+ */
+
+package tech.libeufin.util
+
+import io.ktor.http.*
+import kotlinx.serialization.json.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import java.io.ByteArrayOutputStream
+import java.util.zip.DeflaterOutputStream
+import kotlin.test.assertEquals
+import net.taler.common.errorcodes.TalerErrorCode
+
+/* ----- Json DSL ----- */
+
+inline fun obj(from: JsonObject = JsonObject(emptyMap()), builderAction: JsonBuilder.() -> Unit): JsonObject {
+ val builder = JsonBuilder(from)
+ builder.apply(builderAction)
+ return JsonObject(builder.content)
+}
+
+class JsonBuilder(from: JsonObject) {
+ val content: MutableMap<String, JsonElement> = from.toMutableMap()
+
+ infix inline fun <reified T> String.to(v: T) {
+ val json = Json.encodeToJsonElement(kotlinx.serialization.serializer<T>(), v);
+ content.put(this, json)
+ }
+}
+
+/* ----- Json body helper ----- */
+
+inline fun <reified B> HttpRequestBuilder.json(b: B, deflate: Boolean = false) {
+ val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b);
+ contentType(ContentType.Application.Json)
+ if (deflate) {
+ headers.set("Content-Encoding", "deflate")
+ val bos = ByteArrayOutputStream()
+ val ios = DeflaterOutputStream(bos)
+ ios.write(json.toByteArray())
+ ios.finish()
+ setBody(bos.toByteArray())
+ } else {
+ setBody(json)
+ }
+}
+
+inline fun HttpRequestBuilder.json(
+ from: JsonObject = JsonObject(emptyMap()),
+ deflate: Boolean = false,
+ builderAction: JsonBuilder.() -> Unit
+) {
+ json(obj(from, builderAction), deflate)
+}
+
+inline suspend fun <reified B> HttpResponse.json(): B =
+ Json.decodeFromString(kotlinx.serialization.serializer<B>(), bodyAsText())
+
+inline suspend fun <reified B> HttpResponse.assertOkJson(lambda: (B) -> Unit = {}): B {
+ assertEquals(status, HttpStatusCode.OK)
+ val body = json<B>()
+ lambda(body)
+ return body
+}
+\ No newline at end of file