gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[libeufin] branch master updated (b69d3c14 -> 51d8d79a)


From: gnunet
Subject: [libeufin] branch master updated (b69d3c14 -> 51d8d79a)
Date: Fri, 20 Oct 2023 17:20:37 +0200

This is an automated email from the git hooks/post-receive script.

ms pushed a change to branch master
in repository libeufin.

    from b69d3c14 Fix TalerAmount logic and add amount_mul function in 
preparation for conversion logic
     new d290f862 nexus: moving subcommand into own file
     new 51d8d79a nexus database

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 bank/src/main/kotlin/tech/libeufin/bank/helpers.kt |   2 +-
 database-versioning/libeufin-nexus-0001.sql        |   6 +-
 nexus/build.gradle                                 |   4 +-
 .../main/kotlin/tech/libeufin/nexus/Database.kt    | 145 ++++++
 .../tech/libeufin/nexus/{Main.kt => EbicsSetup.kt} | 356 +++------------
 nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt  | 485 +--------------------
 nexus/src/test/kotlin/Common.kt                    |  20 +
 nexus/src/test/kotlin/DatabaseTest.kt              |  27 ++
 8 files changed, 256 insertions(+), 789 deletions(-)
 create mode 100644 nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
 copy nexus/src/main/kotlin/tech/libeufin/nexus/{Main.kt => EbicsSetup.kt} (70%)
 create mode 100644 nexus/src/test/kotlin/DatabaseTest.kt

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
index d8123ed2..ce283be7 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -1,6 +1,6 @@
 /*
  * This file is part of LibEuFin.
- * Copyright (C) 2019 Stanisci and Dold.
+ * Copyright (C) 2023 Stanisci and Dold.
 
  * LibEuFin is free software; you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as
diff --git a/database-versioning/libeufin-nexus-0001.sql 
b/database-versioning/libeufin-nexus-0001.sql
index f12c3fde..39f3bf5c 100644
--- a/database-versioning/libeufin-nexus-0001.sql
+++ b/database-versioning/libeufin-nexus-0001.sql
@@ -55,9 +55,9 @@ CREATE TABLE IF NOT EXISTS initiated_outgoing_transactions
   ,credit_payto_uri TEXT NOT NULL
   ,outgoing_transaction_id INT8 REFERENCES outgoing_transactions 
(outgoing_transaction_id)
   ,submitted BOOL DEFAULT FALSE 
-  ,hidden BOOL DEFAULT FALSE -- FIXME: exaplain this.
-  ,client_request_uuid TEXT NOT NULL UNIQUE
-    ,failure_message TEXT -- NOTE: that may mix soon failures (those found at 
initiation time), or late failures (those found out along a fetch operation)
+  ,hidden BOOL DEFAULT FALSE -- FIXME: explain this.
+  ,client_request_uuid TEXT UNIQUE
+  ,failure_message TEXT -- NOTE: that may mix soon failures (those found at 
initiation time), or late failures (those found out along a fetch operation)
     );
 
 COMMENT ON COLUMN initiated_outgoing_transactions.outgoing_transaction_id
diff --git a/nexus/build.gradle b/nexus/build.gradle
index 28fecdc3..8a6ea2f3 100644
--- a/nexus/build.gradle
+++ b/nexus/build.gradle
@@ -61,7 +61,9 @@ dependencies {
 
     // Database connection driver
     implementation group: 'org.xerial', name: 'sqlite-jdbc', version: 
'3.36.0.1'
-    implementation 'org.postgresql:postgresql:42.2.23.jre7'
+    // implementation 'org.postgresql:postgresql:42.2.23.jre7'
+    implementation 'org.postgresql:postgresql:42.6.0'
+    implementation 'com.zaxxer:HikariCP:5.0.1'
 
     // Ktor, an HTTP client and server library (no need for nexus-setup)
     implementation "io.ktor:ktor-server-core:$ktor_version"
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
new file mode 100644
index 00000000..9e15ad89
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
@@ -0,0 +1,145 @@
+package tech.libeufin.nexus
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.postgresql.jdbc.PgConnection
+import tech.libeufin.util.pgDataSource
+import com.zaxxer.hikari.*
+import tech.libeufin.util.stripIbanPayto
+import tech.libeufin.util.toDbMicros
+import java.sql.PreparedStatement
+import java.sql.SQLException
+import java.time.Instant
+
+/* only importing TalerAmount from bank ONCE that Nexus has
+* its httpd component.  */
+data class TalerAmount(
+    val value: Long,
+    val fraction: Int,
+    val currency: String
+)
+
+/**
+ * Minimal set of information to initiate a new payment in
+ * the database.
+ */
+data class InitiatedPayment(
+    val amount: TalerAmount,
+    val wireTransferSubject: String,
+    val executionTime: Instant,
+    val creditPaytoUri: String,
+    val clientRequestUuid: String? = null
+)
+
+/**
+ * Possible outcomes for inserting a initiated payment
+ * into the database.
+ */
+enum class PaymentInitiationOutcome {
+    BAD_TIMESTAMP,
+    BAD_CREDIT_PAYTO,
+    UNIQUE_CONSTRAINT_VIOLATION,
+    SUCCESS
+}
+
+/**
+ * Performs a INSERT, UPDATE, or DELETE operation.
+ *
+ * @return true on success, false on unique constraint violation,
+ *         rethrows on any other issue.
+ */
+private fun PreparedStatement.maybeUpdate(): Boolean {
+    try {
+        this.executeUpdate()
+    } catch (e: SQLException) {
+        logger.error(e.message)
+        if (e.sqlState == "23505") return false // unique_violation
+        throw e // rethrowing, not to hide other types of errors.
+    }
+    return true
+}
+
+/**
+ * Collects database connection steps and any operation on the Nexus tables.
+ */
+class Database(dbConfig: String): java.io.Closeable {
+    val dbPool: HikariDataSource
+
+    init {
+        val pgSource = pgDataSource(dbConfig)
+        val config = HikariConfig();
+        config.dataSource = pgSource
+        config.connectionInitSql = "SET search_path TO libeufin_nexus;"
+        config.validate()
+        dbPool = HikariDataSource(config);
+    }
+
+    /**
+     * Closes the database connection.
+     */
+    override fun close() {
+        dbPool.close()
+    }
+
+    /**
+     * Moves the database operations where they can block, without
+     * blocking the whole process.
+     *
+     * @param lambda actual statement preparation and execution logic.
+     * @return what lambda returns.
+     */
+    suspend fun <R> runConn(lambda: suspend (PgConnection) -> R): R {
+        // Use a coroutine dispatcher that we can block as JDBC API is blocking
+        return withContext(Dispatchers.IO) {
+            val conn = dbPool.getConnection()
+            conn.use { it -> lambda(it.unwrap(PgConnection::class.java)) }
+        }
+    }
+
+    /**
+     * Initiate a payment in the database.  The "submit"
+     * command is then responsible to pick it up and submit
+     * it at the bank.
+     *
+     * @param paymentData any data that's used to prepare the payment.
+     * @return true if the insertion went through, false in case of errors.
+     */
+    suspend fun initiatePayment(paymentData: InitiatedPayment): 
PaymentInitiationOutcome = runConn { conn ->
+        val stmt = conn.prepareStatement("""
+           INSERT INTO initiated_outgoing_transactions (
+             amount
+             ,wire_transfer_subject
+             ,execution_time
+             ,credit_payto_uri
+             ,client_request_uuid
+           ) VALUES (
+             (?,?)::taler_amount
+             ,?
+             ,?
+             ,?
+             ,?           
+           )
+        """)
+        stmt.setLong(1, paymentData.amount.value)
+        stmt.setInt(2, paymentData.amount.fraction)
+        stmt.setString(3, paymentData.wireTransferSubject)
+        val executionTime = paymentData.executionTime.toDbMicros() ?: run {
+            logger.error("Execution time could not be converted to 
microseconds for the database.")
+            return@runConn PaymentInitiationOutcome.BAD_TIMESTAMP // nexus 
fault.
+        }
+        stmt.setLong(4, executionTime)
+        val paytoOnlyIban = stripIbanPayto(paymentData.creditPaytoUri) ?: run {
+            logger.error("Credit Payto address is invalid.")
+            return@runConn PaymentInitiationOutcome.BAD_CREDIT_PAYTO // client 
fault.
+        }
+        stmt.setString(5, paytoOnlyIban)
+        stmt.setString(6, paymentData.clientRequestUuid) // can be null.
+        if (stmt.maybeUpdate())
+            return@runConn PaymentInitiationOutcome.SUCCESS
+        /**
+         * _very_ likely, Nexus didn't check the request idempotency,
+         * as the row ID would never fall into the following problem.
+         */
+        return@runConn PaymentInitiationOutcome.UNIQUE_CONSTRAINT_VIOLATION
+    }
+}
\ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
similarity index 70%
copy from nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
copy to nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
index abd5044e..2125d0a0 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
@@ -17,199 +17,24 @@
  * <http://www.gnu.org/licenses/>
  */
 
-/**
- * This file runs the main logic of nexus-setup.  This tool is
- * responsible for reading configuration values about an EBICS
- * subscriber and preparing the key material for further communication
- * with the bank.
- */
-
 package tech.libeufin.nexus
-import ConfigSource
-import TalerConfig
-import TalerConfigError
+
 import com.github.ajalt.clikt.core.CliktCommand
-import com.github.ajalt.clikt.core.subcommands
 import com.github.ajalt.clikt.parameters.options.flag
 import com.github.ajalt.clikt.parameters.options.option
-import com.github.ajalt.clikt.parameters.options.versionOption
 import io.ktor.client.*
-import io.ktor.util.*
 import kotlinx.coroutines.runBlocking
-import kotlinx.serialization.Contextual
-import kotlinx.serialization.KSerializer
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
+import tech.libeufin.util.ebics_h004.EbicsTypes
 import java.io.File
 import kotlin.system.exitProcess
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.descriptors.PrimitiveKind
-import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
-import kotlinx.serialization.descriptors.SerialDescriptor
+import TalerConfig
+import TalerConfigError
 import kotlinx.serialization.encodeToString
-import kotlinx.serialization.encoding.Decoder
-import kotlinx.serialization.encoding.Encoder
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.modules.SerializersModule
-import net.taler.wallet.crypto.Base32Crockford
-import org.slf4j.event.Level
 import tech.libeufin.nexus.ebics.*
 import tech.libeufin.util.*
-import tech.libeufin.util.ebics_h004.EbicsTypes
-import java.security.interfaces.RSAPrivateCrtKey
-import java.security.interfaces.RSAPublicKey
 import java.time.Instant
 import kotlin.reflect.typeOf
 
-val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin-nexus", "libeufin-nexus")
-val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus.Main")
-val myJson = Json {
-    this.serializersModule = SerializersModule {
-        contextual(RSAPrivateCrtKey::class) { RSAPrivateCrtKeySerializer }
-        contextual(RSAPublicKey::class) { RSAPublicKeySerializer }
-    }
-}
-
-/**
- * Keeps all the options of the ebics-setup subcommand.  The
- * caller has to handle TalerConfigError if values are missing.
- * If even one of the fields could not be instantiated, then
- * throws TalerConfigError.
- */
-class EbicsSetupConfig(config: TalerConfig) {
-    // abstracts the section name.
-    private val ebicsSetupRequireString = { option: String ->
-        config.requireString("nexus-ebics", option)
-    }
-    // debug utility to inspect what was loaded.
-    fun _dump() {
-        this.javaClass.declaredFields.forEach {
-            println("cfg obj: ${it.name} -> ${it.get(this)}")
-        }
-    }
-    /**
-     * The bank's currency.
-     */
-    val currency = ebicsSetupRequireString("currency")
-    /**
-     * The bank base URL.
-     */
-    val hostBaseUrl = ebicsSetupRequireString("host_base_url")
-    /**
-     * The bank EBICS host ID.
-     */
-    val ebicsHostId = ebicsSetupRequireString("host_id")
-    /**
-     * EBICS user ID.
-     */
-    val ebicsUserId = ebicsSetupRequireString("user_id")
-    /**
-     * EBICS partner ID.
-     */
-    val ebicsPartnerId = ebicsSetupRequireString("partner_id")
-    /**
-     * EBICS system ID (is this optional?).
-     */
-    val ebicsSystemId = ebicsSetupRequireString("system_id")
-    /**
-     * Bank account name, as given by the bank.  It
-     * can be an IBAN or even any alphanumeric value.
-     */
-    val accountNumber = ebicsSetupRequireString("account_number")
-    /**
-     * Filename where we store the bank public keys.
-     */
-    val bankPublicKeysFilename = 
ebicsSetupRequireString("bank_public_keys_file")
-    /**
-     * Filename where we store our private keys.
-     */
-    val clientPrivateKeysFilename = 
ebicsSetupRequireString("client_private_keys_file")
-    /**
-     * Filename where we store the bank account main information.
-     */
-    val bankAccountMetadataFilename = 
ebicsSetupRequireString("account_meta_data_file")
-    /**
-     * A name that identifies the EBICS and ISO20022 flavour
-     * that Nexus should honor in the communication with the
-     * bank.
-     */
-    val bankDialect: String = ebicsSetupRequireString("bank_dialect").run {
-        if (this != "postfinance") throw Exception("Only 'postfinance' dialect 
is supported.")
-        return@run this
-    }
-}
-
-/**
- * Converts base 32 representation of RSA public keys and vice versa.
- */
-object RSAPublicKeySerializer : KSerializer<RSAPublicKey> {
-    override val descriptor: SerialDescriptor =
-        PrimitiveSerialDescriptor("RSAPublicKey", PrimitiveKind.STRING)
-    override fun serialize(encoder: Encoder, value: RSAPublicKey) {
-        encoder.encodeString(Base32Crockford.encode(value.encoded))
-    }
-
-    // Caller must handle exceptions here.
-    override fun deserialize(decoder: Decoder): RSAPublicKey {
-        val fieldValue = decoder.decodeString()
-        val bytes = Base32Crockford.decode(fieldValue)
-        return CryptoUtil.loadRsaPublicKey(bytes)
-    }
-}
-
-/**
- * Converts base 32 representation of RSA private keys and vice versa.
- */
-object RSAPrivateCrtKeySerializer : KSerializer<RSAPrivateCrtKey> {
-    override val descriptor: SerialDescriptor =
-        PrimitiveSerialDescriptor("RSAPrivateCrtKey", PrimitiveKind.STRING)
-    override fun serialize(encoder: Encoder, value: RSAPrivateCrtKey) {
-        encoder.encodeString(Base32Crockford.encode(value.encoded))
-    }
-
-    // Caller must handle exceptions here.
-    override fun deserialize(decoder: Decoder): RSAPrivateCrtKey {
-        val fieldValue = decoder.decodeString()
-        val bytes = Base32Crockford.decode(fieldValue)
-        return CryptoUtil.loadRsaPrivateKey(bytes)
-    }
-}
-
-/**
- * Structure of the file that holds the bank account
- * metadata.
- */
-@Serializable
-data class BankAccountMetadataFile(
-    val account_holder_iban: String,
-    val bank_code: String?,
-    val account_holder_name: String
-)
-
-/**
- * Structure of the JSON file that contains the client
- * private keys on disk.
- */
-@Serializable
-data class ClientPrivateKeysFile(
-    // FIXME: centralize the @Contextual use.
-    @Contextual val signature_private_key: RSAPrivateCrtKey,
-    @Contextual val encryption_private_key: RSAPrivateCrtKey,
-    @Contextual val authentication_private_key: RSAPrivateCrtKey,
-    var submitted_ini: Boolean,
-    var submitted_hia: Boolean
-)
-
-/**
- * Structure of the JSON file that contains the bank
- * public keys on disk.
- */
-@Serializable
-data class BankPublicKeysFile(
-    @Contextual val bank_encryption_public_key: RSAPublicKey,
-    @Contextual val bank_authentication_public_key: RSAPublicKey,
-    var accepted: Boolean
-)
 /**
  * Writes the JSON content to disk.  Used when we create or update
  * keys and other metadata JSON content to disk.  WARNING: this overrides
@@ -241,7 +66,7 @@ fun generateNewKeys(): ClientPrivateKeysFile =
         signature_private_key = CryptoUtil.generateRsaKeyPair(2048).private,
         submitted_hia = false,
         submitted_ini = false
-)
+    )
 /**
  * Conditionally generates the client private keys and stores them
  * to disk, if the file does not exist already.  Does nothing if the
@@ -265,64 +90,6 @@ fun maybeCreatePrivateKeysFile(filename: String): Boolean {
     return true
 }
 
-/**
- * Load the bank keys file from disk.
- *
- * @param location the keys file location.
- * @return the internal JSON representation of the keys file,
- *         or null on failures.
- */
-fun loadBankKeys(location: String): BankPublicKeysFile? {
-    val f = File(location)
-    if (!f.exists()) {
-        logger.error("Could not find the bank keys file at: $location")
-        return null
-    }
-    val fileContent = try {
-        f.readText() // read from disk.
-    } catch (e: Exception) {
-        logger.error("Could not read the bank keys file from disk, detail: 
${e.message}")
-        return null
-    }
-    return try {
-        myJson.decodeFromString(fileContent) // Parse into JSON.
-    } catch (e: Exception) {
-        logger.error(e.message)
-        @OptIn(InternalAPI::class) // enables message below.
-        logger.error(e.rootCause?.message) // actual useful message mentioning 
failing fields
-        return null
-    }
-}
-
-/**
- * Load the client keys file from disk.
- *
- * @param location the keys file location.
- * @return the internal JSON representation of the keys file,
- *         or null on failures.
- */
-fun loadPrivateKeysFromDisk(location: String): ClientPrivateKeysFile? {
-    val f = File(location)
-    if (!f.exists()) {
-        logger.error("Could not find the private keys file at: $location")
-        return null
-    }
-    val fileContent = try {
-        f.readText() // read from disk.
-    } catch (e: Exception) {
-        logger.error("Could not read private keys from disk, detail: 
${e.message}")
-        return null
-    }
-    return try {
-        myJson.decodeFromString(fileContent) // Parse into JSON.
-    } catch (e: Exception) {
-        logger.error(e.message)
-        @OptIn(InternalAPI::class) // enables message below.
-        logger.error(e.rootCause?.message) // actual useful message mentioning 
failing fields
-        return null
-    }
-}
-
 /**
  * Obtains the client private keys, regardless of them being
  * created for the first time, or read from an existing file
@@ -331,7 +98,7 @@ fun loadPrivateKeysFromDisk(location: String): 
ClientPrivateKeysFile? {
  * @param location path to the file that contains the keys.
  * @return true if the operation succeeds, false otherwise.
  */
-fun preparePrivateKeys(location: String): ClientPrivateKeysFile? {
+private fun preparePrivateKeys(location: String): ClientPrivateKeysFile? {
     if (!maybeCreatePrivateKeysFile(location)) {
         logger.error("Could not create client keys at $location")
         exitProcess(1)
@@ -369,7 +136,7 @@ fun String.spaceEachTwo() =
  * @param bankKeys bank public keys, in format stored on disk.
  * @return true if the user accepted, false otherwise.
  */
-fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean {
+private fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean {
     val encHash = 
CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).toHexString()
     val authHash = 
CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).toHexString()
     println("The bank has the following keys, type 'yes, accept' to accept 
them..\n")
@@ -497,53 +264,6 @@ private fun maybeExtractIban(accountNumberList: 
List<EbicsTypes.AbstractAccountN
 private fun maybeExtractBic(bankCodes: List<EbicsTypes.AbstractBankCode>): 
String? =
     bankCodes.filterIsInstance<EbicsTypes.GeneralBankCode>().find { 
it.international }?.value
 
-/**
- * Mere collector of the PDF generation steps.  Fails the
- * process if a problem occurs.
- *
- * @param privs client private keys.
- * @param cfg configuration handle.
- */
-private fun makePdf(privs: ClientPrivateKeysFile, cfg: EbicsSetupConfig) {
-    val pdf = generateKeysPdf(privs, cfg)
-    val pdfFile = 
File("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf")
-    if (pdfFile.exists()) {
-        logger.error("PDF file exists already at: ${pdfFile.path}, not 
overriding it")
-        exitProcess(1)
-    }
-    try {
-        pdfFile.writeBytes(pdf)
-    } catch (e: Exception) {
-        logger.error("Could not write PDF to ${pdfFile}, detail: ${e.message}")
-        exitProcess(1)
-    }
-    println("PDF file with keys hex encoding created at: $pdfFile")
-}
-
-/**
- * Mere collector of the steps to load and parse the config.
- *
- * @param configFile location of the configuration entry point.
- * @return internal representation of the configuration.
- */
-private fun extractConfig(configFile: String?): EbicsSetupConfig {
-    val config = TalerConfig(NEXUS_CONFIG_SOURCE)
-    try {
-        config.load(configFile)
-    } catch (e: Exception) {
-        logger.error("Could not load configuration from ${configFile}, detail: 
${e.message}")
-        exitProcess(1)
-    }
-    // Checking the config.
-    val cfg = try {
-        EbicsSetupConfig(config)
-    } catch (e: TalerConfigError) {
-        logger.error(e.message)
-        exitProcess(1)
-    }
-    return cfg
-}
-
 private fun findIban(maybeList: List<EbicsTypes.AccountInfo>?): String? {
     if (maybeList == null) {
         logger.warn("Looking for IBAN: bank did not give any account list for 
us.")
@@ -585,6 +305,53 @@ private fun findBic(maybeList: 
List<EbicsTypes.AccountInfo>?): String? {
     return maybeExtractBic(bankCodeList)
 }
 
+/**
+ * Mere collector of the steps to load and parse the config.
+ *
+ * @param configFile location of the configuration entry point.
+ * @return internal representation of the configuration.
+ */
+private fun extractConfig(configFile: String?): EbicsSetupConfig {
+    val config = TalerConfig(NEXUS_CONFIG_SOURCE)
+    try {
+        config.load(configFile)
+    } catch (e: Exception) {
+        logger.error("Could not load configuration from ${configFile}, detail: 
${e.message}")
+        exitProcess(1)
+    }
+    // Checking the config.
+    val cfg = try {
+        EbicsSetupConfig(config)
+    } catch (e: TalerConfigError) {
+        logger.error(e.message)
+        exitProcess(1)
+    }
+    return cfg
+}
+
+/**
+ * Mere collector of the PDF generation steps.  Fails the
+ * process if a problem occurs.
+ *
+ * @param privs client private keys.
+ * @param cfg configuration handle.
+ */
+private fun makePdf(privs: ClientPrivateKeysFile, cfg: EbicsSetupConfig) {
+    val pdf = generateKeysPdf(privs, cfg)
+    val pdfFile = 
File("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf")
+    if (pdfFile.exists()) {
+        logger.error("PDF file exists already at: ${pdfFile.path}, not 
overriding it")
+        exitProcess(1)
+    }
+    try {
+        pdfFile.writeBytes(pdf)
+    } catch (e: Exception) {
+        logger.error("Could not write PDF to ${pdfFile}, detail: ${e.message}")
+        exitProcess(1)
+    }
+    println("PDF file with keys hex encoding created at: $pdfFile")
+}
+
 /**
  * CLI class implementing the "ebics-setup" subcommand.
  */
@@ -744,19 +511,4 @@ class EbicsSetup: CliktCommand() {
         }
         println("setup ready")
     }
-}
-
-/**
- * Main CLI class that collects all the subcommands.
- */
-class LibeufinNexusCommand : CliktCommand() {
-    init {
-        versionOption(getVersion())
-        subcommands(EbicsSetup())
-    }
-    override fun run() = Unit
-}
-
-fun main(args: Array<String>) {
-    LibeufinNexusCommand().main(args)
 }
\ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
index abd5044e..b148c4b2 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -18,12 +18,10 @@
  */
 
 /**
- * This file runs the main logic of nexus-setup.  This tool is
- * responsible for reading configuration values about an EBICS
- * subscriber and preparing the key material for further communication
- * with the bank.
+ * This file collects all the CLI subcommands and runs
+ * them.  The actual implementation of each subcommand is
+ * kept in their respective files.
  */
-
 package tech.libeufin.nexus
 import ConfigSource
 import TalerConfig
@@ -210,60 +208,6 @@ data class BankPublicKeysFile(
     @Contextual val bank_authentication_public_key: RSAPublicKey,
     var accepted: Boolean
 )
-/**
- * Writes the JSON content to disk.  Used when we create or update
- * keys and other metadata JSON content to disk.  WARNING: this overrides
- * silently what's found under the given location!
- *
- * @param obj the class representing the JSON content to store to disk.
- * @param location where to store `obj`
- * @return true in case of success, false otherwise.
- */
-inline fun <reified T> syncJsonToDisk(obj: T, location: String): Boolean {
-    val fileContent = try {
-        myJson.encodeToString(obj)
-    } catch (e: Exception) {
-        logger.error("Could not encode the input '${typeOf<T>()}' to JSON, 
detail: ${e.message}")
-        return false
-    }
-    try {
-        File(location).writeText(fileContent)
-    } catch (e: Exception) {
-        logger.error("Could not write JSON content at $location, detail: 
${e.message}")
-        return false
-    }
-    return true
-}
-fun generateNewKeys(): ClientPrivateKeysFile =
-    ClientPrivateKeysFile(
-        authentication_private_key = 
CryptoUtil.generateRsaKeyPair(2048).private,
-        encryption_private_key = CryptoUtil.generateRsaKeyPair(2048).private,
-        signature_private_key = CryptoUtil.generateRsaKeyPair(2048).private,
-        submitted_hia = false,
-        submitted_ini = false
-)
-/**
- * Conditionally generates the client private keys and stores them
- * to disk, if the file does not exist already.  Does nothing if the
- * file exists.
- *
- * @param filename keys file location
- * @return true if the keys file existed already or its creation
- *         went through, false for any error.
- */
-fun maybeCreatePrivateKeysFile(filename: String): Boolean {
-    val f = File(filename)
-    // NOT overriding any file at the wanted location.
-    if (f.exists()) {
-        logger.debug("Private key file found at: $filename.")
-        return true
-    }
-    val newKeys = generateNewKeys()
-    if (!syncJsonToDisk(newKeys, filename))
-        return false
-    logger.info("New client keys created at: $filename")
-    return true
-}
 
 /**
  * Load the bank keys file from disk.
@@ -323,429 +267,6 @@ fun loadPrivateKeysFromDisk(location: String): 
ClientPrivateKeysFile? {
     }
 }
 
-/**
- * Obtains the client private keys, regardless of them being
- * created for the first time, or read from an existing file
- * on disk.
- *
- * @param location path to the file that contains the keys.
- * @return true if the operation succeeds, false otherwise.
- */
-fun preparePrivateKeys(location: String): ClientPrivateKeysFile? {
-    if (!maybeCreatePrivateKeysFile(location)) {
-        logger.error("Could not create client keys at $location")
-        exitProcess(1)
-    }
-    return loadPrivateKeysFromDisk(location) // loads what found at location.
-}
-
-/**
- * Expresses the type of keying message that the user wants
- * to send to the bank.
- */
-enum class KeysOrderType {
-    INI,
-    HIA,
-    HPB
-}
-
-/**
- * @return the "this" string with a space every two characters.
- */
-fun String.spaceEachTwo() =
-    buildString {
-        this@spaceEachTwo.forEachIndexed { pos, c ->
-            when {
-                (pos == 0) -> this.append(c)
-                (pos % 2 == 0) -> this.append(" $c")
-                else -> this.append(c)
-            }
-        }
-    }
-
-/**
- * Asks the user to accept the bank public keys.
- *
- * @param bankKeys bank public keys, in format stored on disk.
- * @return true if the user accepted, false otherwise.
- */
-fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean {
-    val encHash = 
CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).toHexString()
-    val authHash = 
CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).toHexString()
-    println("The bank has the following keys, type 'yes, accept' to accept 
them..\n")
-    println("Encryption key: ${encHash.spaceEachTwo()}")
-    println("Authentication key: ${authHash.spaceEachTwo()}")
-    val userResponse: String? = readlnOrNull()
-    if (userResponse == "yes, accept")
-        return true
-    return false
-}
-
-/**
- * Parses the HPB response and stores the bank keys as "NOT accepted" to disk.
- *
- * @param cfg used to get the location of the bank keys file.
- * @param bankKeys bank response to the HPB message.
- * @return true if the keys were stored to disk (as "not accepted"),
- *         false if the storage failed or the content was invalid.
- */
-private fun handleHpbResponse(
-    cfg: EbicsSetupConfig,
-    bankKeys: EbicsKeyManagementResponseContent
-): Boolean {
-    val hpbBytes = bankKeys.orderData // silences compiler.
-    if (hpbBytes == null) {
-        logger.error("HPB content not found in a EBICS response with 
successful return codes.")
-        return false
-    }
-    val hpbObj = try {
-        parseEbicsHpbOrder(hpbBytes)
-    }
-    catch (e: Exception) {
-        logger.error("HPB response content seems invalid.")
-        return false
-    }
-    val encPub = try {
-        CryptoUtil.loadRsaPublicKey(hpbObj.encryptionPubKey.encoded)
-    } catch (e: Exception) {
-        logger.error("Could not import bank encryption key from HPB response, 
detail: ${e.message}")
-        return false
-    }
-    val authPub = try {
-        CryptoUtil.loadRsaPublicKey(hpbObj.authenticationPubKey.encoded)
-    } catch (e: Exception) {
-        logger.error("Could not import bank authentication key from HPB 
response, detail: ${e.message}")
-        return false
-    }
-    val json = BankPublicKeysFile(
-        bank_authentication_public_key = authPub,
-        bank_encryption_public_key = encPub,
-        accepted = false
-    )
-    if (!syncJsonToDisk(json, cfg.bankPublicKeysFilename)) {
-        logger.error("Failed to persist the bank keys to disk at: 
${cfg.bankPublicKeysFilename}")
-        return false
-    }
-    return true
-}
-
-/**
- * Collects all the steps from generating the message, to
- * sending it to the bank, and finally updating the state
- * on disk according to the response.
- *
- * @param cfg handle to the configuration.
- * @param privs bundle of all the private keys of the client.
- * @param client the http client that requests to the bank.
- * @param orderType INI or HIA.
- * @param autoAcceptBankKeys only given in case of HPB.  Expresses
- *        the --auto-accept-key CLI flag.
- * @return true if the message fulfilled its purpose AND the state
- *         on disk was accordingly updated, or false otherwise.
- */
-suspend fun doKeysRequestAndUpdateState(
-    cfg: EbicsSetupConfig,
-    privs: ClientPrivateKeysFile,
-    client: HttpClient,
-    orderType: KeysOrderType
-): Boolean {
-    val req = when(orderType) {
-        KeysOrderType.INI -> generateIniMessage(cfg, privs)
-        KeysOrderType.HIA -> generateHiaMessage(cfg, privs)
-        KeysOrderType.HPB -> generateHpbMessage(cfg, privs)
-    }
-    val xml = client.postToBank(cfg.hostBaseUrl, req)
-    if (xml == null) {
-        logger.error("Could not POST the ${orderType.name} message to the 
bank")
-        return false
-    }
-    val ebics = parseKeysMgmtResponse(privs.encryption_private_key, xml)
-    if (ebics == null) {
-        logger.error("Could not get any EBICS from the bank ${orderType.name} 
response ($xml).")
-        return false
-    }
-    if (ebics.technicalReturnCode != EbicsReturnCode.EBICS_OK) {
-        logger.error("EBICS ${orderType.name} failed with code: 
${ebics.technicalReturnCode}")
-        return false
-    }
-    if (ebics.bankReturnCode != EbicsReturnCode.EBICS_OK) {
-        logger.error("EBICS ${orderType.name} reached the bank, but could not 
be fulfilled, error code: ${ebics.bankReturnCode}")
-        return false
-    }
-
-    when(orderType) {
-        KeysOrderType.INI -> privs.submitted_ini = true
-        KeysOrderType.HIA -> privs.submitted_hia = true
-        KeysOrderType.HPB -> return handleHpbResponse(cfg, ebics)
-    }
-    if (!syncJsonToDisk(privs, cfg.clientPrivateKeysFilename)) {
-        logger.error("Could not update the ${orderType.name} state on disk")
-        return false
-    }
-    return true
-}
-
-/**
- * Abstracts (part of) the IBAN extraction from an HTD response.
- */
-private fun maybeExtractIban(accountNumberList: 
List<EbicsTypes.AbstractAccountNumber>): String? =
-    accountNumberList.filterIsInstance<EbicsTypes.GeneralAccountNumber>().find 
{ it.international }?.value
-
-/**
- * Abstracts (part of) the BIC extraction from an HTD response.
- */
-private fun maybeExtractBic(bankCodes: List<EbicsTypes.AbstractBankCode>): 
String? =
-    bankCodes.filterIsInstance<EbicsTypes.GeneralBankCode>().find { 
it.international }?.value
-
-/**
- * Mere collector of the PDF generation steps.  Fails the
- * process if a problem occurs.
- *
- * @param privs client private keys.
- * @param cfg configuration handle.
- */
-private fun makePdf(privs: ClientPrivateKeysFile, cfg: EbicsSetupConfig) {
-    val pdf = generateKeysPdf(privs, cfg)
-    val pdfFile = 
File("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf")
-    if (pdfFile.exists()) {
-        logger.error("PDF file exists already at: ${pdfFile.path}, not 
overriding it")
-        exitProcess(1)
-    }
-    try {
-        pdfFile.writeBytes(pdf)
-    } catch (e: Exception) {
-        logger.error("Could not write PDF to ${pdfFile}, detail: ${e.message}")
-        exitProcess(1)
-    }
-    println("PDF file with keys hex encoding created at: $pdfFile")
-}
-
-/**
- * Mere collector of the steps to load and parse the config.
- *
- * @param configFile location of the configuration entry point.
- * @return internal representation of the configuration.
- */
-private fun extractConfig(configFile: String?): EbicsSetupConfig {
-    val config = TalerConfig(NEXUS_CONFIG_SOURCE)
-    try {
-        config.load(configFile)
-    } catch (e: Exception) {
-        logger.error("Could not load configuration from ${configFile}, detail: 
${e.message}")
-        exitProcess(1)
-    }
-    // Checking the config.
-    val cfg = try {
-        EbicsSetupConfig(config)
-    } catch (e: TalerConfigError) {
-        logger.error(e.message)
-        exitProcess(1)
-    }
-    return cfg
-}
-
-private fun findIban(maybeList: List<EbicsTypes.AccountInfo>?): String? {
-    if (maybeList == null) {
-        logger.warn("Looking for IBAN: bank did not give any account list for 
us.")
-        return null
-    }
-    if (maybeList.size != 1) {
-        logger.warn("Looking for IBAN: bank gave account list, but it was not 
a singleton.")
-        return null
-    }
-    val accountNumberList = maybeList[0].accountNumberList
-    if (accountNumberList == null) {
-        logger.warn("Bank gave account list, but no IBAN list of found.")
-        return null
-    }
-    if (accountNumberList.size != 1) {
-        logger.warn("Bank gave account list, but IBAN list was not singleton.")
-        return null
-    }
-    return maybeExtractIban(accountNumberList)
-}
-private fun findBic(maybeList: List<EbicsTypes.AccountInfo>?): String? {
-    if (maybeList == null) {
-        logger.warn("Looking for BIC: bank did not give any account list for 
us.")
-        return null
-    }
-    if (maybeList.size != 1) {
-        logger.warn("Looking for BIC: bank gave account list, but it was not a 
singleton.")
-        return null
-    }
-    val bankCodeList = maybeList[0].bankCodeList
-    if (bankCodeList == null) {
-        logger.warn("Bank gave account list, but no BIC list of found.")
-        return null
-    }
-    if (bankCodeList.size != 1) {
-        logger.warn("Bank gave account list, but BIC list was not singleton.")
-        return null
-    }
-    return maybeExtractBic(bankCodeList)
-}
-
-/**
- * CLI class implementing the "ebics-setup" subcommand.
- */
-class EbicsSetup: CliktCommand() {
-    private val configFile by option(
-        "--config", "-c",
-        help = "set the configuration file"
-    )
-    private val checkFullConfig by option(
-        help = "checks config values of ALL the subcommands"
-    ).flag(default = false)
-    private val forceKeysResubmission by option(
-        help = "resubmits all the keys to the bank"
-    ).flag(default = false)
-    private val autoAcceptKeys by option(
-        help = "accepts the bank keys without the user confirmation"
-    ).flag(default = false)
-    private val generateRegistrationPdf by option(
-        help = "generates the PDF with the client public keys to send to the 
bank"
-    ).flag(default = false)
-    private val showAssociatedAccounts by option(
-        help = "shows which bank accounts belong to the EBICS subscriber"
-    ).flag(default = false)
-
-    /**
-     * This function collects the main steps of setting up an EBICS access.
-     */
-    override fun run() {
-        val cfg = extractConfig(this.configFile)
-        if (checkFullConfig) {
-            throw NotImplementedError("--check-full-config flag not 
implemented")
-        }
-        // Config is sane.  Go (maybe) making the private keys.
-        val privsMaybe = preparePrivateKeys(cfg.clientPrivateKeysFilename)
-        if (privsMaybe == null) {
-            logger.error("Private keys preparation failed.")
-            exitProcess(1)
-        }
-        val httpClient = HttpClient()
-        // Privs exist.  Upload their pubs
-        val keysNotSub = !privsMaybe.submitted_ini || !privsMaybe.submitted_hia
-        runBlocking {
-            if ((!privsMaybe.submitted_ini) || forceKeysResubmission)
-                doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, 
KeysOrderType.INI).apply { if (!this) exitProcess(1) }
-            if ((!privsMaybe.submitted_hia) || forceKeysResubmission)
-                doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, 
KeysOrderType.HIA).apply { if (!this) exitProcess(1) }
-        }
-        // Reloading new state from disk if any upload (and therefore a disk 
write) actually took place
-        val haveSubmitted = forceKeysResubmission || keysNotSub
-        val privs = if (haveSubmitted) {
-            logger.info("Keys submitted to the bank, at ${cfg.hostBaseUrl}")
-            loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
-        } else privsMaybe
-        if (privs == null) {
-            logger.error("Could not reload private keys from disk after 
submission")
-            exitProcess(1)
-        }
-        // Really both must be submitted here.
-        if ((!privs.submitted_hia) || (!privs.submitted_ini)) {
-            logger.error("Cannot continue with non-submitted client keys.")
-            exitProcess(1)
-        }
-        // Eject PDF if the keys were submitted for the first time, or the 
user asked.
-        if (keysNotSub || generateRegistrationPdf) makePdf(privs, cfg)
-        // Checking if the bank keys exist on disk.
-        val bankKeysFile = File(cfg.bankPublicKeysFilename)
-        if (!bankKeysFile.exists()) { // FIXME: should this also check the 
content validity?
-            val areKeysOnDisk = runBlocking {
-                doKeysRequestAndUpdateState(
-                    cfg,
-                    privs,
-                    httpClient,
-                    KeysOrderType.HPB
-                )
-            }
-            if (!areKeysOnDisk) {
-                logger.error("Could not download bank keys.  Send client keys 
(and/or related PDF document with --generate-registration-pdf) to the bank.")
-                exitProcess(1)
-            }
-            logger.info("Bank keys stored at ${cfg.bankPublicKeysFilename}")
-        }
-        // bank keys made it to the disk, check if they're accepted.
-        val bankKeysMaybe = loadBankKeys(cfg.bankPublicKeysFilename)
-        if (bankKeysMaybe == null) {
-            logger.error("Although previous checks, could not load the bank 
keys file from: ${cfg.bankPublicKeysFilename}")
-            exitProcess(1)
-        }
-        /**
-         * The following block potentially updates the bank keys state
-         * on disk, if that's the first time that they become accepted.
-         * If so, finally reloads the bank keys file from disk.
-         */
-        val bankKeys = if (!bankKeysMaybe.accepted) {
-
-            if (autoAcceptKeys) bankKeysMaybe.accepted = true
-            else bankKeysMaybe.accepted = askUserToAcceptKeys(bankKeysMaybe)
-
-            if (!bankKeysMaybe.accepted) {
-                logger.error("Cannot continue without accepting the bank 
keys.")
-                exitProcess(1)
-            }
-
-            if (!syncJsonToDisk(bankKeysMaybe, cfg.bankPublicKeysFilename)) {
-                logger.error("Could not set bank keys as accepted on disk.")
-                exitProcess(1)
-            }
-            // Reloading after the disk write above.
-            loadBankKeys(cfg.bankPublicKeysFilename) ?: kotlin.run {
-                logger.error("Could not reload bank keys after disk write.")
-                exitProcess(1)
-            }
-        } else
-            bankKeysMaybe // keys were already accepted.
-
-        // Downloading the list of owned bank account(s).
-        val bankAccounts = runBlocking {
-            fetchBankAccounts(cfg, privs, bankKeys, httpClient)
-        }
-        if (bankAccounts == null) {
-            logger.error("Could not obtain the list of bank accounts from the 
bank.")
-            exitProcess(1)
-        }
-        logger.info("Subscriber's bank accounts fetched.")
-        // Now trying to extract whatever IBAN & BIC pair the bank gave in the 
response.
-        val foundIban: String? = 
findIban(bankAccounts.partnerInfo.accountInfoList)
-        val foundBic: String? = 
findBic(bankAccounts.partnerInfo.accountInfoList)
-        // _some_ IBAN & BIC _might_ have been found, compare it with the 
config.
-        if (foundIban == null)
-            logger.warn("Bank seems NOT to show any IBAN for our account.")
-        if (foundBic == null)
-            logger.warn("Bank seems NOT to show any BIC for our account.")
-        // Warn the user if instead one IBAN was found but that differs from 
the config.
-        if (foundIban != null && foundIban != cfg.accountNumber) {
-            logger.error("Bank has another IBAN for us: $foundIban, while 
config has: ${cfg.accountNumber}")
-            exitProcess(1)
-        }
-        // Users wants only _see_ the accounts, NOT checking values and 
returning here.
-        if (showAssociatedAccounts) {
-            println("Bank associates this account to the EBICS user 
${cfg.ebicsUserId}: IBAN: $foundIban, BIC: $foundBic, Name: 
${bankAccounts.userInfo.name}")
-            return
-        }
-        // No divergences were found, either because the config was right
-        // _or_ the bank didn't give any information.  Setting the account
-        // metadata accordingly.
-        val accountMetaData = BankAccountMetadataFile(
-            account_holder_name = bankAccounts.userInfo.name ?: "Account 
holder name not given",
-            account_holder_iban = foundIban ?: run iban@ {
-                logger.warn("Bank did not show any IBAN for us, defaulting to 
the one we configured.")
-                return@iban cfg.accountNumber },
-            bank_code = foundBic ?: run bic@ {
-                logger.warn("Bank did not show any BIC for us, setting it as 
null.")
-                return@bic null }
-        )
-        if (!syncJsonToDisk(accountMetaData, cfg.bankAccountMetadataFilename)) 
{
-            logger.error("Failed to persist bank account meta-data at: 
${cfg.bankAccountMetadataFilename}")
-            exitProcess(1)
-        }
-        println("setup ready")
-    }
-}
-
 /**
  * Main CLI class that collects all the subcommands.
  */
diff --git a/nexus/src/test/kotlin/Common.kt b/nexus/src/test/kotlin/Common.kt
index b9284822..4845aaa8 100644
--- a/nexus/src/test/kotlin/Common.kt
+++ b/nexus/src/test/kotlin/Common.kt
@@ -4,6 +4,9 @@ import io.ktor.client.request.*
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.modules.SerializersModule
 import tech.libeufin.nexus.*
+import tech.libeufin.util.DatabaseConfig
+import tech.libeufin.util.initializeDatabaseTables
+import tech.libeufin.util.resetDatabaseTables
 import java.security.interfaces.RSAPrivateCrtKey
 
 val j = Json {
@@ -18,6 +21,23 @@ val config: EbicsSetupConfig = run {
     EbicsSetupConfig(handle)
 }
 
+fun prepDb(cfg: TalerConfig): Database {
+    cfg.loadDefaults()
+    val dbCfg = DatabaseConfig(
+        dbConnStr = "postgresql:///libeufincheck",
+        sqlDir = cfg.requirePath("paths", "datadir") + "sql"
+    )
+    println("SQL dir for testing: ${dbCfg.sqlDir}")
+    try {
+        resetDatabaseTables(dbCfg, "libeufin-nexus")
+    } catch (e: Exception) {
+        logger.warn("Resetting an empty database throws, tolerating this...")
+        logger.warn(e.message)
+    }
+    initializeDatabaseTables(dbCfg, "libeufin-nexus")
+    return Database(dbCfg.dbConnStr)
+}
+
 val clientKeys = generateNewKeys()
 
 // Gets an HTTP client whose requests are going to be served by 'handler'.
diff --git a/nexus/src/test/kotlin/DatabaseTest.kt 
b/nexus/src/test/kotlin/DatabaseTest.kt
new file mode 100644
index 00000000..2858af8e
--- /dev/null
+++ b/nexus/src/test/kotlin/DatabaseTest.kt
@@ -0,0 +1,27 @@
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import tech.libeufin.nexus.InitiatedPayment
+import tech.libeufin.nexus.NEXUS_CONFIG_SOURCE
+import tech.libeufin.nexus.PaymentInitiationOutcome
+import tech.libeufin.nexus.TalerAmount
+import java.time.Instant
+import kotlin.test.assertEquals
+
+class DatabaseTest {
+
+    @Test
+    fun paymentInitiation() {
+        val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE))
+        val initPay = InitiatedPayment(
+            amount = TalerAmount(44, 0, "KUDOS"),
+            creditPaytoUri = "payto://iban/not-used",
+            executionTime = Instant.now(),
+            wireTransferSubject = "test",
+            clientRequestUuid = "unique"
+        )
+        runBlocking {
+            assertEquals(db.initiatePayment(initPay), 
PaymentInitiationOutcome.SUCCESS)
+            assertEquals(db.initiatePayment(initPay), 
PaymentInitiationOutcome.UNIQUE_CONSTRAINT_VIOLATION)
+        }
+    }
+}
\ No newline at end of file

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]