gnunet-svn
[Top][All Lists]
Advanced

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

[libeufin] branch master updated (fc72eb5f -> af3b104f)


From: gnunet
Subject: [libeufin] branch master updated (fc72eb5f -> af3b104f)
Date: Tue, 09 Apr 2024 15:48:21 +0200

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

antoine pushed a change to branch master
in repository libeufin.

    from fc72eb5f ci: remove the leading v character from tags
     new ed18c6cb Move more API and DB logic in common in preparation for 
libeufin-nexus REST API
     new af3b104f nexus: wire gateway /admin/add-incoming

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/build.gradle                                  |   5 -
 .../main/kotlin/tech/libeufin/bank/Constants.kt    |   4 -
 bank/src/main/kotlin/tech/libeufin/bank/Error.kt   | 111 +---------
 bank/src/main/kotlin/tech/libeufin/bank/Main.kt    | 193 ++----------------
 .../main/kotlin/tech/libeufin/bank/TalerCommon.kt  |  71 +------
 .../main/kotlin/tech/libeufin/bank/TalerMessage.kt |  93 ---------
 .../tech/libeufin/bank/api/BankIntegrationApi.kt   |   2 +-
 .../kotlin/tech/libeufin/bank/api/ConversionApi.kt |   7 +-
 .../kotlin/tech/libeufin/bank/api/CoreBankApi.kt   |   4 +-
 .../kotlin/tech/libeufin/bank/api/RevenueApi.kt    |   1 +
 .../tech/libeufin/bank/api/WireGatewayApi.kt       |   3 +-
 .../main/kotlin/tech/libeufin/bank/auth/auth.kt    |   5 +-
 .../kotlin/tech/libeufin/bank/db/CashoutDAO.kt     |   8 +-
 .../kotlin/tech/libeufin/bank/db/ConversionDAO.kt  |   7 +-
 .../main/kotlin/tech/libeufin/bank/db/Database.kt  | 150 ++++++--------
 .../kotlin/tech/libeufin/bank/db/ExchangeDAO.kt    |  16 +-
 .../tech/libeufin/bank/db/NotificationWatcher.kt   | 151 --------------
 .../main/kotlin/tech/libeufin/bank/db/TanDAO.kt    |   5 +-
 .../kotlin/tech/libeufin/bank/db/TransactionDAO.kt |   4 +-
 .../kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt  |  10 +-
 bank/src/main/kotlin/tech/libeufin/bank/helpers.kt |   2 +-
 bank/src/main/kotlin/tech/libeufin/bank/params.kt  |  69 +------
 bank/src/test/kotlin/AmountTest.kt                 |   5 +-
 bank/src/test/kotlin/CommonApiTest.kt              |   2 +-
 bank/src/test/kotlin/ConversionApiTest.kt          |   3 +-
 bank/src/test/kotlin/DatabaseTest.kt               |   1 +
 bank/src/test/kotlin/JsonTest.kt                   |   2 +-
 bank/src/test/kotlin/PaytoTest.kt                  |   4 +-
 bank/src/test/kotlin/RevenueApiTest.kt             |   1 +
 bank/src/test/kotlin/SecurityTest.kt               |   5 +-
 bank/src/test/kotlin/WireGatewayApiTest.kt         |   2 -
 bank/src/test/kotlin/helpers.kt                    |  27 ---
 bank/src/test/kotlin/routines.kt                   |   4 +-
 common/build.gradle                                |  11 +-
 .../Error.kt => common/src/main/kotlin/ApiError.kt |  72 ++-----
 common/src/main/kotlin/Client.kt                   |  31 ++-
 common/src/main/kotlin/Constants.kt                |   8 +-
 common/src/main/kotlin/TalerCommon.kt              |  80 ++++++++
 common/src/main/kotlin/TalerMessage.kt             | 103 ++++++++++
 common/src/main/kotlin/api/server.kt               | 225 +++++++++++++++++++++
 common/src/main/kotlin/db/helpers.kt               | 113 +++++++++++
 common/src/main/kotlin/db/notifications.kt         |  91 +++++++++
 .../main/kotlin/db/{utils.kt => transaction.kt}    |   0
 common/src/main/kotlin/params.kt                   |  95 +++++++++
 common/src/test/kotlin/AmountTest.kt               |   2 +-
 contrib/nexus.conf                                 |  11 +-
 database-versioning/libeufin-nexus-procedures.sql  |  16 +-
 nexus/build.gradle                                 |   2 +
 .../main/kotlin/tech/libeufin/nexus/EbicsFetch.kt  |  16 +-
 nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt  |  13 ++
 .../tech/libeufin/nexus/api/WireGatewayApi.kt      | 134 ++++++++++++
 .../kotlin/tech/libeufin/nexus/db/PaymentDAO.kt    |  21 +-
 .../src/test/kotlin/WireGatewayApiTest.kt          |  68 +++----
 nexus/src/test/kotlin/helpers.kt                   |  17 ++
 54 files changed, 1123 insertions(+), 983 deletions(-)
 delete mode 100644 
bank/src/main/kotlin/tech/libeufin/bank/db/NotificationWatcher.kt
 copy bank/src/main/kotlin/tech/libeufin/bank/Error.kt => 
common/src/main/kotlin/ApiError.kt (59%)
 create mode 100644 common/src/main/kotlin/TalerMessage.kt
 create mode 100644 common/src/main/kotlin/api/server.kt
 create mode 100644 common/src/main/kotlin/db/helpers.kt
 create mode 100644 common/src/main/kotlin/db/notifications.kt
 rename common/src/main/kotlin/db/{utils.kt => transaction.kt} (100%)
 create mode 100644 common/src/main/kotlin/params.kt
 create mode 100644 
nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt
 copy {bank => nexus}/src/test/kotlin/WireGatewayApiTest.kt (78%)

diff --git a/bank/build.gradle b/bank/build.gradle
index 9070480a..01b884ab 100644
--- a/bank/build.gradle
+++ b/bank/build.gradle
@@ -27,13 +27,8 @@ dependencies {
     implementation("com.github.ajalt.clikt:clikt:$clikt_version")
 
     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-serialization-kotlinx-json:$ktor_version")
-    implementation("io.ktor:ktor-server-forwarded-header:$ktor_version")
 
     // UNIX domain sockets support (used to connect to PostgreSQL)
     
implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version")
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
index 4bcd2ee1..d91d779d 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
@@ -36,12 +36,8 @@ val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L)
 val RESERVED_ACCOUNTS = setOf("admin", "bank") 
 const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5
 
-// Security
-const val MAX_BODY_LENGTH: Long = 4 * 1024 // 4kB
-
 // API version  
 const val COREBANK_API_VERSION: String = "4:7:0"
 const val CONVERSION_API_VERSION: String = "0:0:0"
 const val INTEGRATION_API_VERSION: String = "2:0:2"
-const val WIRE_GATEWAY_API_VERSION: String = "0:2:0"
 const val REVENUE_API_VERSION: String = "0:0:0"
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
index 18735633..9c5a115d 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
@@ -23,110 +23,7 @@ import io.ktor.server.application.*
 import io.ktor.server.response.*
 import io.ktor.util.*
 import kotlinx.serialization.Serializable
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.TalerErrorCode
-
-/**
- * Convenience type to throw errors along the bank activity
- * and that is meant to be caught by Ktor and responded to the
- * client.
- */
-class LibeufinException(
-    // Status code that Ktor will set for the response.
-    val httpStatus: HttpStatusCode,
-    // Error detail object, after Taler API.
-    val talerError: TalerError
-) : Exception(talerError.hint)
-
-/**
- * Error object to respond to the client.  The
- * 'code' field takes values from the GANA gnu-taler-error-code
- * specification.  'hint' is a human-readable description
- * of the error.
- */
-@Serializable
-data class TalerError(
-    @kotlinx.serialization.Transient val err: TalerErrorCode = 
TalerErrorCode.END,
-    val code: Int,
-    val hint: String? = null,
-    val detail: String? = null
-)
-
-private val LOG_MSG = AttributeKey<String>("log_msg")
-
-fun ApplicationCall.logMsg(): String? = attributes.getOrNull(LOG_MSG)
-
-suspend fun ApplicationCall.err(
-    status: HttpStatusCode,
-    hint: String?,
-    error: TalerErrorCode
-) {
-    err(
-        LibeufinException(
-            httpStatus = status, talerError = TalerError(
-                code = error.code, err = error, hint = hint
-            )
-        )
-    )
-}
-
-suspend fun ApplicationCall.err(
-    err: LibeufinException
-) {
-    attributes.put(LOG_MSG, "${err.talerError.err.name} 
${err.talerError.hint}")
-    respond(
-        status = err.httpStatus,
-        message = err.talerError
-    )
-}
-
-
-fun libeufinError(
-    status: HttpStatusCode,
-    hint: String?,
-    error: TalerErrorCode,
-    detail: String? = null
-): LibeufinException = LibeufinException(
-    httpStatus = status, talerError = TalerError(
-        code = error.code, err = error, hint = hint, detail = detail
-    )
-)
-
-/* ----- HTTP error ----- */
-
-fun forbidden(
-    hint: String = "No rights on the resource",
-    error: TalerErrorCode = TalerErrorCode.END
-): LibeufinException = libeufinError(HttpStatusCode.Forbidden, hint, error)
-
-fun unauthorized(
-    hint: String,
-    error: TalerErrorCode = TalerErrorCode.GENERIC_UNAUTHORIZED
-): LibeufinException = libeufinError(HttpStatusCode.Unauthorized, hint, error)
-
-fun internalServerError(hint: String?): LibeufinException 
-    = libeufinError(HttpStatusCode.InternalServerError, hint, 
TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE)
-
-fun notFound(
-    hint: String,
-    error: TalerErrorCode
-): LibeufinException = libeufinError(HttpStatusCode.NotFound, hint, error)
-
-fun conflict(
-    hint: String, error: TalerErrorCode
-): LibeufinException = libeufinError(HttpStatusCode.Conflict, hint, error)
-
-fun badRequest(
-    hint: String? = null, 
-    error: TalerErrorCode = TalerErrorCode.GENERIC_JSON_INVALID,
-    detail: String? = null
-): LibeufinException = libeufinError(HttpStatusCode.BadRequest, hint, error, 
detail)
-
-fun unsupportedMediaType(
-    hint: String, 
-    error: TalerErrorCode = TalerErrorCode.END,
-): LibeufinException = libeufinError(HttpStatusCode.UnsupportedMediaType, 
hint, error)
-
+import tech.libeufin.common.*
 
 /* ----- Currency checks ----- */
 
@@ -146,21 +43,21 @@ fun BankConfig.checkFiatCurrency(amount: TalerAmount) {
 
 /* ----- Common errors ----- */
 
-fun unknownAccount(id: String): LibeufinException {
+fun unknownAccount(id: String): ApiException {
     return notFound(
         "Account '$id' not found",
         TalerErrorCode.BANK_UNKNOWN_ACCOUNT
     )
 }
 
-fun unknownCreditorAccount(id: String): LibeufinException {
+fun unknownCreditorAccount(id: String): ApiException {
     return conflict(
         "Creditor account '$id' not found",
         TalerErrorCode.BANK_UNKNOWN_CREDITOR
     )
 }
 
-fun unsupportedTanChannel(channel: TanChannel): LibeufinException {
+fun unsupportedTanChannel(channel: TanChannel): ApiException {
     return conflict(
         "Unsupported tan channel $channel",
         TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
index 1e28af34..8733b907 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -35,12 +35,6 @@ import io.ktor.server.application.*
 import io.ktor.server.engine.*
 import io.ktor.server.http.content.*
 import io.ktor.server.netty.*
-import io.ktor.server.plugins.*
-import io.ktor.server.plugins.callloging.*
-import io.ktor.server.plugins.contentnegotiation.*
-import io.ktor.server.plugins.cors.routing.*
-import io.ktor.server.plugins.forwardedheaders.*
-import io.ktor.server.plugins.statuspages.*
 import io.ktor.server.request.*
 import io.ktor.server.response.*
 import io.ktor.server.routing.*
@@ -55,6 +49,7 @@ import tech.libeufin.bank.api.*
 import tech.libeufin.bank.db.AccountDAO.*
 import tech.libeufin.bank.db.Database
 import tech.libeufin.common.*
+import tech.libeufin.common.api.*
 import tech.libeufin.common.db.dbInit
 import tech.libeufin.common.db.pgDataSource
 import java.net.InetAddress
@@ -68,187 +63,23 @@ import kotlin.io.path.readText
 
 private val logger: Logger = LoggerFactory.getLogger("libeufin-bank")
 // Dirty local variable to stop the server in test TODO remove this ugly hack
-var engine: ApplicationEngine? = null 
+var engine: ApplicationEngine? = null
 
-/**
- * This plugin checks for body length limit and inflates the requests that 
have "Content-Encoding: deflate"
- */
-val bodyPlugin = createApplicationPlugin("BodyLimitAndDecompression") {
-    onCallReceive { call ->
-        // TODO check content length as an optimisation
-        transformBody { body ->
-            val bytes = ByteArray(MAX_BODY_LENGTH.toInt() + 1)
-            var read = 0
-            when (val encoding = 
call.request.headers[HttpHeaders.ContentEncoding])  {
-                "deflate" -> {
-                    // Decompress and check decompressed length
-                    val inflater = Inflater()
-                    while (!body.isClosedForRead) {
-                        body.read { buf ->
-                            inflater.setInput(buf)
-                            try {
-                                read += inflater.inflate(bytes, read, 
bytes.size - read)
-                            } catch (e: DataFormatException) {
-                                logger.error("Deflated request failed to 
inflate: ${e.message}")
-                                throw badRequest(
-                                    "Could not inflate request",
-                                    TalerErrorCode.GENERIC_COMPRESSION_INVALID
-                                )
-                            }
-                        }
-                        if (read > MAX_BODY_LENGTH)
-                            throw badRequest("Decompressed body is 
suspiciously big > $MAX_BODY_LENGTH B")
-                    }
-                }
-                null -> {
-                    // Check body length
-                    while (true) {
-                        val new = body.readAvailable(bytes, read, bytes.size - 
read)
-                        if (new == -1) break // Channel is closed
-                        read += new
-                        if (read > MAX_BODY_LENGTH)
-                            throw badRequest("Body is suspiciously big > 
$MAX_BODY_LENGTH B")
-                    }
-                } 
-                else -> throw unsupportedMediaType(
-                    "Content encoding '$encoding' not supported, expected 
plain or deflate",
-                    TalerErrorCode.GENERIC_COMPRESSION_INVALID
-                )
-            }
-            ByteReadChannel(bytes, 0, read)
-        }
-    }
-}
 
 /**
  * Set up web server handlers for the Taler corebank API.
  */
-fun Application.corebankWebApp(db: Database, ctx: BankConfig) {
-    install(CallLogging) {
-        this.level = Level.INFO
-        this.logger = tech.libeufin.bank.logger
-        this.format { call ->
-            val status = call.response.status()
-            val httpMethod = call.request.httpMethod.value
-            val path = call.request.path()
-            val msg = call.logMsg()
-            if (msg != null) {
-                "$status, $httpMethod $path, $msg"
-            } else {
-                "$status, $httpMethod $path"
-            }
-        }
-    }
-    install(XForwardedHeaders)
-    install(CORS) {
-        anyHost()
-        allowHeader(HttpHeaders.Authorization)
-        allowHeader(HttpHeaders.ContentType)
-        allowMethod(HttpMethod.Options)
-        allowMethod(HttpMethod.Patch)
-        allowMethod(HttpMethod.Delete)
-        allowCredentials = true
-    }
-    install(bodyPlugin)
-    install(IgnoreTrailingSlash)
-    install(ContentNegotiation) {
-        json(Json {
-            @OptIn(ExperimentalSerializationApi::class)
-            explicitNulls = false
-            encodeDefaults = true
-            ignoreUnknownKeys = true
-        })
-    }
-    install(StatusPages) {
-        status(HttpStatusCode.NotFound) { call, status ->
-            call.err(
-                status,
-                "There is no endpoint defined for the URL provided by the 
client. Check if you used the correct URL and/or file a report with the 
developers of the client software.",
-                TalerErrorCode.GENERIC_ENDPOINT_UNKNOWN
-            )
-        }
-        status(HttpStatusCode.MethodNotAllowed) { call, status ->
-            call.err(
-                status,
-                "The HTTP method used is invalid for this endpoint. This is 
likely a bug in the client implementation. Check if you are using the latest 
available version and/or file a report with the developers.",
-                TalerErrorCode.GENERIC_METHOD_INVALID
-            )
-        }
-        exception<Exception> { call, cause ->
-            logger.debug("request failed", cause)
-            when (cause) {
-                is LibeufinException -> call.err(cause)
-                is SQLException -> {
-                    when (cause.sqlState) {
-                        PSQLState.SERIALIZATION_FAILURE.state -> call.err(
-                            HttpStatusCode.InternalServerError,
-                            "Transaction serialization failure",
-                            TalerErrorCode.BANK_SOFT_EXCEPTION
-                        )
-                        else -> call.err(
-                            HttpStatusCode.InternalServerError,
-                            "Unexpected sql error with state 
${cause.sqlState}",
-                            TalerErrorCode.BANK_UNMANAGED_EXCEPTION
-                        )
-                    }
-                }
-                is BadRequestException -> {
-                    /**
-                     * NOTE: extracting the root cause helps with JSON error 
messages,
-                     * because they mention the particular way they are 
invalid, but OTOH
-                     * it loses (by getting null) other error messages, like 
for example
-                     * the one from MissingRequestParameterException.  
Therefore, in order
-                     * to get the most detailed message, we must consider BOTH 
sides:
-                     * the 'cause' AND its root cause!
-                     */
-                    var rootCause: Throwable? = cause.cause
-                    while (rootCause?.cause != null)
-                        rootCause = rootCause.cause
-                    // Telling apart invalid JSON vs missing parameter vs 
invalid parameter.
-                    val talerErrorCode = when {
-                        cause is MissingRequestParameterException ->
-                            TalerErrorCode.GENERIC_PARAMETER_MISSING
-                        cause is ParameterConversionException ->
-                            TalerErrorCode.GENERIC_PARAMETER_MALFORMED
-                        rootCause is CommonError -> when (rootCause) {
-                            is CommonError.AmountFormat -> 
TalerErrorCode.BANK_BAD_FORMAT_AMOUNT
-                            is CommonError.AmountNumberTooBig -> 
TalerErrorCode.BANK_NUMBER_TOO_BIG
-                            is CommonError.Payto -> 
TalerErrorCode.GENERIC_JSON_INVALID
-                        }
-                        else -> TalerErrorCode.GENERIC_JSON_INVALID
-                    }
-                    call.err(
-                        badRequest(
-                            cause.message,
-                            talerErrorCode,
-                            /* Here getting _some_ error message, by giving 
precedence
-                            * to the root cause, as otherwise JSON details 
would be lost. */
-                            rootCause?.message
-                        )
-                    )
-                }
-                else -> {
-                    call.err(
-                        HttpStatusCode.InternalServerError,
-                        cause.message,
-                        TalerErrorCode.BANK_UNMANAGED_EXCEPTION
-                    )
-                }
-            }
-        }
-    }
-    routing {
-        coreBankApi(db, ctx)
-        conversionApi(db, ctx)
-        bankIntegrationApi(db, ctx)
-        wireGatewayApi(db, ctx)
-        revenueApi(db, ctx)
-        ctx.spaPath?.let {
-            get("/") {
-                call.respondRedirect("/webui/")
-            }
-            staticFiles("/webui/", it.toFile())
+fun Application.corebankWebApp(db: Database, ctx: BankConfig) = 
talerApi(logger) {
+    coreBankApi(db, ctx)
+    conversionApi(db, ctx)
+    bankIntegrationApi(db, ctx)
+    wireGatewayApi(db, ctx)
+    revenueApi(db, ctx)
+    ctx.spaPath?.let {
+        get("/") {
+            call.respondRedirect("/webui/")
         }
+        staticFiles("/webui/", it.toFile())
     }
 }
 
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
index bb148166..5966cba9 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
@@ -30,57 +30,13 @@ import kotlinx.serialization.json.JsonDecoder
 import kotlinx.serialization.json.JsonElement
 import kotlinx.serialization.json.jsonPrimitive
 import kotlinx.serialization.json.longOrNull
-import tech.libeufin.common.TalerAmount
+import tech.libeufin.common.*
 import java.net.URL
 import java.time.Duration
 import java.time.Instant
 import java.time.temporal.ChronoUnit
 import java.util.concurrent.TimeUnit
 
-/** Timestamp containing the number of seconds since epoch */
-@Serializable
-data class TalerProtocolTimestamp(
-    @Serializable(with = Serializer::class)
-    val t_s: Instant,
-) {
-    companion object {
-        fun fromMicroseconds(uSec: Long): TalerProtocolTimestamp {
-            return TalerProtocolTimestamp(
-                Instant.EPOCH.plus(uSec, ChronoUnit.MICROS)
-            )
-        }
-    }
-
-    internal object Serializer : KSerializer<Instant> {
-        override fun serialize(encoder: Encoder, value: Instant) {
-            if (value == Instant.MAX) {
-                encoder.encodeString("never")
-            } else {
-                encoder.encodeLong(value.epochSecond)
-            }
-            
-        }
-    
-        override fun deserialize(decoder: Decoder): Instant {
-            val jsonInput = decoder as? JsonDecoder ?: error("Can be 
deserialized only by JSON")
-            val maybeTs = jsonInput.decodeJsonElement().jsonPrimitive
-            if (maybeTs.isString) {
-                if (maybeTs.content != "never") throw badRequest("Only 'never' 
allowed for t_s as string, but '${maybeTs.content}' was found")
-                return Instant.MAX
-            }
-            val ts: Long = maybeTs.longOrNull
-                ?: throw badRequest("Could not convert t_s 
'${maybeTs.content}' to a number")
-            when {
-                ts < 0 -> throw badRequest("Negative timestamp not allowed")
-                ts > Instant.MAX.epochSecond -> throw badRequest("Timestamp 
$ts too big to be represented in Kotlin")
-                else -> return Instant.ofEpochSecond(ts)
-            }
-        }
-    
-        override val descriptor: SerialDescriptor = 
JsonElement.serializer().descriptor
-    }
-}
-
 @Serializable(with = DecimalNumber.Serializer::class)
 class DecimalNumber {
     val value: Long
@@ -180,29 +136,4 @@ data class RelativeTime(
     companion object {
         private const val MAX_SAFE_INTEGER = 9007199254740991L // 2^53 - 1
     }
-}
-
-
-@Serializable(with = ExchangeUrl.Serializer::class)
-class ExchangeUrl {
-    val url: String
-
-    constructor(raw: String) {
-        url = URL(raw).toString()
-    }
-
-    override fun toString(): String = url
-
-    internal object Serializer : KSerializer<ExchangeUrl> {
-        override val descriptor: SerialDescriptor =
-                PrimitiveSerialDescriptor("ExchangeUrl", PrimitiveKind.STRING)
-
-        override fun serialize(encoder: Encoder, value: ExchangeUrl) {
-            encoder.encodeString(value.toString())
-        }
-
-        override fun deserialize(decoder: Decoder): ExchangeUrl {
-            return ExchangeUrl(decoder.decodeString())
-        }
-    }
 }
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
index 67f77bd9..2527bae6 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -327,14 +327,6 @@ data class TalerIntegrationConfigResponse(
     val version: String = INTEGRATION_API_VERSION
 }
 
-@Serializable
-data class WireGatewayConfig(
-    val currency: String
-) {
-    val name: String = "taler-wire-gateway"
-    val version: String = WIRE_GATEWAY_API_VERSION
-}
-
 @Serializable
 data class RevenueConfig(
     val currency: String
@@ -554,70 +546,6 @@ data class ConversionResponse(
     val amount_credit: TalerAmount,
 )
 
-/**
- * Request to an /admin/add-incoming request from
- * the Taler Wire Gateway API.
- */
-@Serializable
-data class AddIncomingRequest(
-    val amount: TalerAmount,
-    val reserve_pub: EddsaPublicKey,
-    val debit_account: Payto
-)
-
-/**
- * Response to /admin/add-incoming
- */
-@Serializable
-data class AddIncomingResponse(
-    val timestamp: TalerProtocolTimestamp,
-    val row_id: Long
-)
-
-/**
- * Response of a TWG /history/incoming call.
- */
-@Serializable
-data class IncomingHistory(
-    val incoming_transactions: List<IncomingReserveTransaction>,
-    val credit_account: String
-)
-
-/**
- * TWG's incoming payment record.
- */
-@Serializable
-data class IncomingReserveTransaction(
-    val type: String = "RESERVE",
-    val row_id: Long, // DB row ID of the payment.
-    val date: TalerProtocolTimestamp,
-    val amount: TalerAmount,
-    val debit_account: String,
-    val reserve_pub: EddsaPublicKey
-)
-
-/**
- * Response of a TWG /history/outgoing call.
- */
-@Serializable
-data class OutgoingHistory(
-    val outgoing_transactions: List<OutgoingTransaction>,
-    val debit_account: String
-)
-
-/**
- * TWG's outgoinf payment record.
- */
-@Serializable
-data class OutgoingTransaction(
-    val row_id: Long, // DB row ID of the payment.
-    val date: TalerProtocolTimestamp,
-    val amount: TalerAmount,
-    val credit_account: String,
-    val wtid: ShortHashCode,
-    val exchange_base_url: String,
-)
-
 @Serializable
 data class RevenueIncomingHistory(
     val incoming_transactions : List<RevenueIncomingBankTransaction>,
@@ -633,27 +561,6 @@ data class RevenueIncomingBankTransaction(
     val subject: String
 )
 
-/**
- * TWG's request to pay a merchant.
- */
-@Serializable
-data class TransferRequest(
-    val request_uid: HashCode,
-    val amount: TalerAmount,
-    val exchange_base_url: ExchangeUrl,
-    val wtid: ShortHashCode,
-    val credit_account: Payto
-)
-
-/**
- * TWG's response to merchant payouts
- */
-@Serializable
-data class TransferResponse(
-    val timestamp: TalerProtocolTimestamp,
-    val row_id: Long
-)
-
 /**
  * Response to GET /public-accounts
  */
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt
index 05505214..b88d8fa1 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt
@@ -30,7 +30,7 @@ import tech.libeufin.bank.*
 import tech.libeufin.bank.db.AbortResult
 import tech.libeufin.bank.db.Database
 import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalSelectionResult
-import tech.libeufin.common.TalerErrorCode
+import tech.libeufin.common.*
 
 fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) {
     get("/taler-integration/config") {
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt
index 8dcc1f60..2a466db6 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt
@@ -28,14 +28,13 @@ import tech.libeufin.bank.auth.authAdmin
 import tech.libeufin.bank.db.ConversionDAO
 import tech.libeufin.bank.db.ConversionDAO.ConversionResult
 import tech.libeufin.bank.db.Database
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.TalerErrorCode
+import tech.libeufin.common.*
 
 fun Routing.conversionApi(db: Database, ctx: BankConfig) = 
conditional(ctx.allowConversion) {
     get("/conversion-info/config") {
         val config = db.conversion.getConfig(ctx.regionalCurrency, 
ctx.fiatCurrency!!)
         if (config == null) {
-            throw libeufinError(
+            throw apiError(
                 HttpStatusCode.NotImplemented, 
                 "conversion rate not configured yet", 
                 TalerErrorCode.END
@@ -62,7 +61,7 @@ fun Routing.conversionApi(db: Database, ctx: BankConfig) = 
conditional(ctx.allow
                 "$input is too small to be converted",
                 TalerErrorCode.BANK_BAD_CONVERSION
             )
-            is ConversionResult.MissingConfig -> throw libeufinError(
+            is ConversionResult.MissingConfig -> throw apiError(
                 HttpStatusCode.NotImplemented, 
                 "conversion rate not configured yet", 
                 TalerErrorCode.END
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
index 1ef1ace5..c659b0f0 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
@@ -699,7 +699,7 @@ private fun Routing.coreBankTanApi(db: Database, ctx: 
BankConfig) {
                             exitValue
                         }
                         if (exitValue != 0) {
-                            throw libeufinError(
+                            throw apiError(
                                 HttpStatusCode.BadGateway,
                                 "Tan channel script failure with exit value 
$exitValue",
                                 TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
@@ -733,7 +733,7 @@ private fun Routing.coreBankTanApi(db: Database, ctx: 
BankConfig) {
                     "Incorrect TAN code",
                     TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
                 )
-                TanSolveResult.NoRetry -> throw libeufinError(
+                TanSolveResult.NoRetry -> throw apiError(
                     HttpStatusCode.TooManyRequests,
                     "Too many failed confirmation attempt",
                     TalerErrorCode.BANK_TAN_RATE_LIMITED
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt
index f91054ce..b46cf875 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt
@@ -25,6 +25,7 @@ import io.ktor.server.routing.*
 import tech.libeufin.bank.*
 import tech.libeufin.bank.auth.auth
 import tech.libeufin.bank.db.Database
+import tech.libeufin.common.*
 
 fun Routing.revenueApi(db: Database, ctx: BankConfig) { 
     auth(db, TokenScope.readonly) {
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt
index 5b10f7d1..8a35de4c 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt
@@ -35,8 +35,7 @@ import tech.libeufin.bank.db.Database
 import tech.libeufin.bank.db.ExchangeDAO
 import tech.libeufin.bank.db.ExchangeDAO.AddIncomingResult
 import tech.libeufin.bank.db.ExchangeDAO.TransferResult
-import tech.libeufin.common.BankPaytoCtx
-import tech.libeufin.common.TalerErrorCode
+import tech.libeufin.common.*
 import java.time.Instant
 
 
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
index 83bde34f..c0ed1100 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
@@ -27,11 +27,8 @@ import io.ktor.util.*
 import io.ktor.util.pipeline.*
 import tech.libeufin.bank.*
 import tech.libeufin.bank.db.Database
-import tech.libeufin.common.Base32Crockford
-import tech.libeufin.common.TalerErrorCode
+import tech.libeufin.common.*
 import tech.libeufin.common.crypto.PwCrypto
-import tech.libeufin.common.decodeBase64
-import tech.libeufin.common.splitOnce
 import java.time.Instant
 
 /** Used to store if the currently authenticated user is admin */
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
index c4f25657..799d466a 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
@@ -20,12 +20,8 @@
 package tech.libeufin.bank.db
 
 import tech.libeufin.bank.*
-import tech.libeufin.common.ShortHashCode
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.asInstant
-import tech.libeufin.common.db.getAmount
-import tech.libeufin.common.db.oneOrNull
-import tech.libeufin.common.micros
+import tech.libeufin.common.*
+import tech.libeufin.common.db.*
 import java.time.Instant
 
 /** Data access logic for cashout operations */
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt
index 5849881d..04317b68 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt
@@ -22,11 +22,8 @@ package tech.libeufin.bank.db
 import tech.libeufin.bank.ConversionRate
 import tech.libeufin.bank.DecimalNumber
 import tech.libeufin.bank.RoundingMode
-import tech.libeufin.bank.internalServerError
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.db.getAmount
-import tech.libeufin.common.db.oneOrNull
-import tech.libeufin.common.db.transaction
+import tech.libeufin.common.*
+import tech.libeufin.common.db.*
 
 /** Data access logic for conversion */
 class ConversionDAO(private val db: Database) {
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
index b6e6fd7e..9efb821a 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
@@ -27,17 +27,17 @@ import kotlinx.coroutines.withTimeoutOrNull
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
 import tech.libeufin.bank.*
-import tech.libeufin.common.asInstant
+import tech.libeufin.common.*
 import tech.libeufin.common.db.*
 import java.sql.PreparedStatement
 import java.sql.ResultSet
-import kotlin.math.abs
+import java.util.concurrent.ConcurrentHashMap
+import java.util.*
 
 private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-db")
 
 class Database(dbConfig: DatabaseConfig, internal val bankCurrency: String, 
internal val fiatCurrency: String?): DbPool(dbConfig, "libeufin-bank") {
-    internal val notifWatcher: NotificationWatcher = 
NotificationWatcher(pgSource)
-
+    // DAOs
     val cashout = CashoutDAO(this)
     val withdrawal = WithdrawalDAO(this)
     val exchange = ExchangeDAO(this)
@@ -48,6 +48,67 @@ class Database(dbConfig: DatabaseConfig, internal val 
bankCurrency: String, inte
     val tan = TanDAO(this)
     val gc = GcDAO(this)
 
+    // Transaction flows, the keys are the bank account id
+    private val bankTxFlows = ConcurrentHashMap<Long, 
CountedSharedFlow<Long>>()
+    private val outgoingTxFlows = ConcurrentHashMap<Long, 
CountedSharedFlow<Long>>()
+    private val incomingTxFlows = ConcurrentHashMap<Long, 
CountedSharedFlow<Long>>()
+    private val revenueTxFlows = ConcurrentHashMap<Long, 
CountedSharedFlow<Long>>()
+    // Withdrawal confirmation flow, the key is the public withdrawal UUID
+    private val withdrawalFlow = ConcurrentHashMap<UUID, 
CountedSharedFlow<WithdrawalStatus>>()
+ 
+    init {
+        watchNotifications(pgSource, "libeufin_bank", 
LoggerFactory.getLogger("libeufin-bank-db-watcher"), mapOf(
+            "bank_tx" to {
+                val (debtor, creditor, debitRow, creditRow) = it.split(' ', 
limit = 4).map { it.toLong() }
+                bankTxFlows[debtor]?.run {
+                    flow.emit(debitRow)
+                }
+                bankTxFlows[creditor]?.run {
+                    flow.emit(creditRow)
+                }
+                revenueTxFlows[creditor]?.run {
+                    flow.emit(creditRow)
+                }
+            },
+            "outgoing_tx" to {
+                val (account, merchant, debitRow, creditRow) = it.split(' ', 
limit = 4).map { it.toLong() }
+                outgoingTxFlows[account]?.run {
+                    flow.emit(debitRow)
+                }
+            },
+            "incoming_tx" to {
+                val (account, row) = it.split(' ', limit = 2).map { 
it.toLong() }
+                incomingTxFlows[account]?.run {
+                    flow.emit(row)
+                }
+            },
+            "withdrawal_status" to {
+                val raw = it.split(' ', limit = 2)
+                val uuid = UUID.fromString(raw[0])
+                val status = WithdrawalStatus.valueOf(raw[1])
+                withdrawalFlow[uuid]?.run {
+                    flow.emit(status)
+                }
+            }
+        ))
+    }
+
+    /** Listen for new bank transactions for [account] */
+    suspend fun <R> listenBank(account: Long, lambda: suspend (Flow<Long>) -> 
R): R
+        = listen(bankTxFlows, account, lambda)
+    /** Listen for new taler outgoing transactions from [account] */
+    suspend fun <R> listenOutgoing(exchange: Long, lambda: suspend 
(Flow<Long>) -> R): R
+        = listen(outgoingTxFlows, exchange, lambda)
+    /** Listen for new taler incoming transactions to [account] */
+    suspend fun <R> listenIncoming(exchange: Long, lambda: suspend 
(Flow<Long>) -> R): R
+        = listen(incomingTxFlows, exchange, lambda)
+    /** Listen for new taler outgoing transactions to [account] */
+    suspend fun <R> listenRevenue(merchant: Long, lambda: suspend (Flow<Long>) 
-> R): R
+        = listen(revenueTxFlows, merchant, lambda)
+    /** Listen for new withdrawal confirmations */
+    suspend fun <R> listenWithdrawals(withdrawal: UUID, lambda: suspend 
(Flow<WithdrawalStatus>) -> R): R
+        = listen(withdrawalFlow, withdrawal, lambda)
+
     suspend fun monitor(
         params: MonitorParams
     ): MonitorResponse = conn { conn ->
@@ -87,7 +148,7 @@ class Database(dbConfig: DatabaseConfig, internal val 
bankCurrency: String, inte
                     talerOutCount = it.getLong("taler_out_count"),
                     talerOutVolume = it.getAmount("taler_out_volume", 
bankCurrency),
                 )
-            } ?:  MonitorNoConversion(
+            } ?: MonitorNoConversion(
                 talerInCount = it.getLong("taler_in_count"),
                 talerInVolume = it.getAmount("taler_in_volume", bankCurrency),
                 talerOutCount = it.getLong("taler_out_count"),
@@ -95,85 +156,6 @@ class Database(dbConfig: DatabaseConfig, internal val 
bankCurrency: String, inte
             )
         } ?: throw internalServerError("No result from DB procedure 
stats_get_frame")
     }
-
-    /** Apply paging logic to a sql query */
-    internal suspend fun <T> page(
-        params: PageParams,
-        idName: String,
-        query: String,
-        bind: PreparedStatement.() -> Int = { 0 },
-        map: (ResultSet) -> T
-    ): List<T> = conn { conn ->
-        val backward = params.delta < 0
-        val pageQuery = """
-            $query
-            $idName ${if (backward) '<' else '>'} ?
-            ORDER BY $idName ${if (backward) "DESC" else "ASC"}
-            LIMIT ?
-        """
-        conn.prepareStatement(pageQuery).run {
-            val pad = bind()
-            setLong(pad + 1, params.start)
-            setInt(pad + 2, abs(params.delta))
-            all { map(it) }
-        }
-    }
-
-    /**
-    * The following function returns the list of transactions, according
-    * to the history parameters and perform long polling when necessary
-    */
-    internal suspend fun <T> poolHistory(
-        params: HistoryParams, 
-        bankAccountId: Long,
-        listen: suspend NotificationWatcher.(Long, suspend (Flow<Long>) -> 
List<T>) -> List<T>,
-        query: String,
-        accountColumn: String = "bank_account_id",
-        map: (ResultSet) -> T
-    ): List<T> {
-
-        suspend fun load(): List<T> = page(
-            params.page, 
-            "bank_transaction_id", 
-            "$query $accountColumn=? AND", 
-            {
-                setLong(1, bankAccountId)
-                1
-            },
-            map
-        )
-            
-
-        // TODO do we want to handle polling when going backward and there is 
no transactions yet ?
-        // When going backward there is always at least one transaction or none
-        return if (params.page.delta >= 0 && params.polling.poll_ms > 0) {
-            notifWatcher.(listen)(bankAccountId) { flow ->
-                coroutineScope {
-                    // Start buffering notification before loading 
transactions to not miss any
-                    val polling = launch {
-                        withTimeoutOrNull(params.polling.poll_ms) {
-                            flow.first { it > params.page.start } // Always 
forward so >
-                        }
-                    }    
-                    // Initial loading
-                    val init = load()
-                    // Long polling if we found no transactions
-                    if (init.isEmpty()) {
-                        if (polling.join() != null) {
-                            load()
-                        } else {
-                            init
-                        }
-                    } else {
-                        polling.cancel()
-                        init
-                    }
-                }
-            }
-        } else {
-            load()
-        }
-    }
 }
 
 /** Result status of withdrawal or cashout operation abortion */
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt
index 3ae15c7e..5991c394 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt
@@ -20,12 +20,8 @@
 package tech.libeufin.bank.db
 
 import tech.libeufin.bank.*
-import tech.libeufin.common.BankPaytoCtx
-import tech.libeufin.common.EddsaPublicKey
-import tech.libeufin.common.ShortHashCode
-import tech.libeufin.common.db.getAmount
-import tech.libeufin.common.db.getBankPayto
-import tech.libeufin.common.micros
+import tech.libeufin.common.*
+import tech.libeufin.common.db.*
 import java.time.Instant
 
 /** Data access logic for exchange specific logic */
@@ -36,7 +32,7 @@ class ExchangeDAO(private val db: Database) {
         exchangeId: Long,
         ctx: BankPaytoCtx
     ): List<IncomingReserveTransaction> 
-        = db.poolHistory(params, exchangeId, 
NotificationWatcher::listenIncoming,  """
+        = db.poolHistory(params, exchangeId, db::listenIncoming,  """
             SELECT
                 bank_transaction_id
                 ,transaction_date
@@ -65,7 +61,7 @@ class ExchangeDAO(private val db: Database) {
         exchangeId: Long,
         ctx: BankPaytoCtx
     ): List<OutgoingTransaction> 
-        = db.poolHistory(params, exchangeId, 
NotificationWatcher::listenOutgoing,  """
+        = db.poolHistory(params, exchangeId, db::listenOutgoing,  """
             SELECT
                 bank_transaction_id
                 ,transaction_date
@@ -198,10 +194,8 @@ class ExchangeDAO(private val db: Database) {
         stmt.setString(6, login)
         stmt.setLong(7, now.micros())
 
-        stmt.executeQuery().use {
+        stmt.one {
             when {
-                !it.next() ->
-                    throw internalServerError("SQL function taler_add_incoming 
did not return anything.")
                 it.getBoolean("out_creditor_not_found") -> 
AddIncomingResult.UnknownExchange 
                 it.getBoolean("out_creditor_not_exchange") -> 
AddIncomingResult.NotAnExchange
                 it.getBoolean("out_debtor_not_found") -> 
AddIncomingResult.UnknownDebtor
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/NotificationWatcher.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/NotificationWatcher.kt
deleted file mode 100644
index 077cb771..00000000
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/NotificationWatcher.kt
+++ /dev/null
@@ -1,151 +0,0 @@
-/*
- * 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.bank.db
-
-import kotlinx.coroutines.*
-import kotlinx.coroutines.flow.*
-import org.postgresql.ds.PGSimpleDataSource
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-import tech.libeufin.bank.*
-import tech.libeufin.common.*
-import tech.libeufin.common.db.*
-import java.util.*
-import java.util.concurrent.ConcurrentHashMap
-
-private val logger: Logger = 
LoggerFactory.getLogger("libeufin-bank-db-watcher")
-
-/** Postgres notification collector and distributor */
-internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) {
-    // ShareFlow that are manually counted for manual garbage collection
-    private class CountedSharedFlow<T>(val flow: MutableSharedFlow<T>, var 
count: Int)
-
-    // Transaction flows, the keys are the bank account id
-    private val bankTxFlows = ConcurrentHashMap<Long, 
CountedSharedFlow<Long>>()
-    private val outgoingTxFlows = ConcurrentHashMap<Long, 
CountedSharedFlow<Long>>()
-    private val incomingTxFlows = ConcurrentHashMap<Long, 
CountedSharedFlow<Long>>()
-    private val revenueTxFlows = ConcurrentHashMap<Long, 
CountedSharedFlow<Long>>()
-    // Withdrawal confirmation flow, the key is the public withdrawal UUID
-    private val withdrawalFlow = ConcurrentHashMap<UUID, 
CountedSharedFlow<WithdrawalStatus>>()
-
-    private val backoff = ExpoBackoffDecorr()
-
-    init {
-        // Run notification logic in a separated thread
-        kotlin.concurrent.thread(isDaemon = true) { 
-            runBlocking {
-                while (true) {
-                    try {
-                        val conn = pgSource.pgConnection("libeufin_bank")
-
-                        // Listen to all notifications channels
-                        conn.execSQLUpdate("LISTEN bank_tx")
-                        conn.execSQLUpdate("LISTEN outgoing_tx")
-                        conn.execSQLUpdate("LISTEN incoming_tx")
-                        conn.execSQLUpdate("LISTEN withdrawal_status")
-
-                        backoff.reset()
-
-                        while (true) {
-                            conn.getNotifications(0) // Block until we receive 
at least one notification
-                                .forEach {
-                                // Extract information and dispatch
-                                when (it.name) {
-                                    "bank_tx" -> {
-                                        val (debtor, creditor, debitRow, 
creditRow) = it.parameter.split(' ', limit = 4).map { it.toLong() }
-                                        bankTxFlows[debtor]?.run {
-                                            flow.emit(debitRow)
-                                        }
-                                        bankTxFlows[creditor]?.run {
-                                            flow.emit(creditRow)
-                                        }
-                                        revenueTxFlows[creditor]?.run {
-                                            flow.emit(creditRow)
-                                        }
-                                    }
-                                    "outgoing_tx" -> {
-                                        val (account, merchant, debitRow, 
creditRow) = it.parameter.split(' ', limit = 4).map { it.toLong() }
-                                        outgoingTxFlows[account]?.run {
-                                            flow.emit(debitRow)
-                                        }
-                                    }
-                                    "incoming_tx" -> {
-                                        val (account, row) = 
it.parameter.split(' ', limit = 2).map { it.toLong() }
-                                        incomingTxFlows[account]?.run {
-                                            flow.emit(row)
-                                        }
-                                    }
-                                    "withdrawal_status" -> {
-                                        val raw = it.parameter.split(' ', 
limit = 2)
-                                        val uuid = UUID.fromString(raw[0])
-                                        val status = 
WithdrawalStatus.valueOf(raw[1])
-                                        withdrawalFlow[uuid]?.run {
-                                            flow.emit(status)
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    } catch (e: Exception) {
-                        e.fmtLog(logger)
-                        delay(backoff.next())
-                    }
-                }
-            }
-        }
-    }
-
-    /** Listen to flow from [map] for [key] using [lambda]*/
-    private suspend fun <R, K, V> listen(map: ConcurrentHashMap<K, 
CountedSharedFlow<V>>, key: K, lambda: suspend (Flow<V>) -> R): R {
-        // Register listener, create a new flow if missing
-        val flow = map.compute(key) { _, v ->
-            val tmp = v ?: CountedSharedFlow(MutableSharedFlow(), 0)
-            tmp.count++
-            tmp
-        }!!.flow
-
-        try {
-            return lambda(flow)
-        } finally {
-            // Unregister listener, removing unused flow
-            map.compute(key) { _, v ->
-                v!!
-                v.count--
-                if (v.count > 0) v else null
-            }
-        }
-    } 
-
-    /** Listen for new bank transactions for [account] */
-    suspend fun <R> listenBank(account: Long, lambda: suspend (Flow<Long>) -> 
R): R
-        = listen(bankTxFlows, account, lambda)
-    /** Listen for new taler outgoing transactions from [account] */
-    suspend fun <R> listenOutgoing(exchange: Long, lambda: suspend 
(Flow<Long>) -> R): R
-        = listen(outgoingTxFlows, exchange, lambda)
-    /** Listen for new taler incoming transactions to [account] */
-    suspend fun <R> listenIncoming(exchange: Long, lambda: suspend 
(Flow<Long>) -> R): R
-        = listen(incomingTxFlows, exchange, lambda)
-    /** Listen for new taler outgoing transactions to [account] */
-    suspend fun <R> listenRevenue(merchant: Long, lambda: suspend (Flow<Long>) 
-> R): R
-        = listen(revenueTxFlows, merchant, lambda)
-    /** Listen for new withdrawal confirmations */
-    suspend fun <R> listenWithdrawals(withdrawal: UUID, lambda: suspend 
(Flow<WithdrawalStatus>) -> R): R
-        = listen(withdrawalFlow, withdrawal, lambda)
-}
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
index 388d530a..524d5578 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
@@ -21,9 +21,8 @@ package tech.libeufin.bank.db
 
 import tech.libeufin.bank.Operation
 import tech.libeufin.bank.TanChannel
-import tech.libeufin.bank.internalServerError
-import tech.libeufin.common.db.oneOrNull
-import tech.libeufin.common.micros
+import tech.libeufin.common.*
+import tech.libeufin.common.db.*
 import java.time.Duration
 import java.time.Instant
 import java.util.concurrent.TimeUnit
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
index 7fb8823d..794eb04d 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
@@ -189,7 +189,7 @@ class TransactionDAO(private val db: Database) {
         accountId: Long,
         ctx: BankPaytoCtx
     ): List<BankAccountTransactionInfo> {
-        return db.poolHistory(params, accountId, 
NotificationWatcher::listenBank,  """
+        return db.poolHistory(params, accountId, db::listenBank,  """
             SELECT
                 bank_transaction_id
                 ,transaction_date
@@ -221,7 +221,7 @@ class TransactionDAO(private val db: Database) {
         accountId: Long,
         ctx: BankPaytoCtx
     ): List<RevenueIncomingBankTransaction> 
-        = db.poolHistory(params, accountId, 
NotificationWatcher::listenRevenue, """
+        = db.poolHistory(params, accountId, db::listenRevenue, """
             SELECT
                 bank_transaction_id
                 ,transaction_date
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
index 6a02205c..efaa4b74 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
@@ -24,12 +24,8 @@ import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withTimeoutOrNull
 import tech.libeufin.bank.*
-import tech.libeufin.common.EddsaPublicKey
-import tech.libeufin.common.Payto
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.db.getAmount
-import tech.libeufin.common.db.oneOrNull
-import tech.libeufin.common.micros
+import tech.libeufin.common.*
+import tech.libeufin.common.db.*
 import java.time.Instant
 import java.util.*
 
@@ -207,7 +203,7 @@ class WithdrawalDAO(private val db: Database) {
         load: suspend () -> T?
     ): T? {
         return if (params.polling.poll_ms > 0) {
-            db.notifWatcher.listenWithdrawals(uuid) { flow ->
+            db.listenWithdrawals(uuid) { flow ->
                 coroutineScope {
                     // Start buffering notification before loading 
transactions to not miss any
                     val polling = launch {
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
index e502f62c..094b7996 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -149,7 +149,7 @@ fun Route.intercept(callback: Route.() -> Unit, 
interceptor: suspend PipelineCon
 fun Route.conditional(implemented: Boolean, callback: Route.() -> Unit): Route 
=
     intercept(callback) {
         if (!implemented) {
-            throw libeufinError(HttpStatusCode.NotImplemented, "API not 
implemented", TalerErrorCode.END)
+            throw apiError(HttpStatusCode.NotImplemented, "API not 
implemented", TalerErrorCode.END)
         }
     }
 
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/params.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/params.kt
index 72e6cabd..3f34fb36 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/params.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/params.kt
@@ -20,44 +20,13 @@
 package tech.libeufin.bank
 
 import io.ktor.http.*
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.TalerErrorCode
+import tech.libeufin.common.*
 import java.time.Instant
 import java.time.LocalDateTime
 import java.time.ZoneOffset
 import java.time.temporal.TemporalAdjusters
 import java.util.*
 
-fun Parameters.expect(name: String): String 
-    = get(name) ?: throw badRequest("Missing '$name' parameter", 
TalerErrorCode.GENERIC_PARAMETER_MISSING)
-fun Parameters.int(name: String): Int? 
-    = get(name)?.run { toIntOrNull() ?: throw badRequest("Param '$name' not a 
number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) }
-fun Parameters.expectInt(name: String): Int 
-    = int(name) ?: throw badRequest("Missing '$name' number parameter", 
TalerErrorCode.GENERIC_PARAMETER_MISSING)
-fun Parameters.long(name: String): Long? 
-    = get(name)?.run { toLongOrNull() ?: throw badRequest("Param '$name' not a 
number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) }
-fun Parameters.expectLong(name: String): Long 
-    = long(name) ?: throw badRequest("Missing '$name' number parameter", 
TalerErrorCode.GENERIC_PARAMETER_MISSING)
-fun Parameters.uuid(name: String): UUID? {
-    return get(name)?.run {
-        try {
-            UUID.fromString(this)
-        } catch (e: Exception) {
-            throw badRequest("Param '$name' not an UUID", 
TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
-        }
-    } 
-}
-fun Parameters.expectUuid(name: String): UUID 
-    = uuid(name) ?: throw badRequest("Missing '$name' UUID parameter", 
TalerErrorCode.GENERIC_PARAMETER_MISSING)
-fun Parameters.amount(name: String): TalerAmount? 
-    = get(name)?.run { 
-        try {
-            TalerAmount(this)
-        } catch (e: Exception) {
-            throw badRequest("Param '$name' not a taler amount", 
TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
-        }
-    }
-
 data class MonitorParams(
     val timeframe: Timeframe,
     val date: LocalDateTime
@@ -125,42 +94,6 @@ data class AccountParams(
     }
 }
 
-data class PageParams(
-    val delta: Int, val start: Long
-) {
-    companion object {
-        fun extract(params: Parameters): PageParams {
-            val delta: Int = params.int("delta") ?: -20
-            val start: Long = params.long("start") ?: if (delta >= 0) 0L else 
Long.MAX_VALUE
-            if (start < 0) throw badRequest("Param 'start' must be a positive 
number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
-            // TODO enforce delta limit
-            return PageParams(delta, start)
-        }
-    }
-}
-
-data class PollingParams(
-    val poll_ms: Long
-) {
-    companion object {
-        fun extract(params: Parameters): PollingParams {
-            val poll_ms: Long = params.long("long_poll_ms") ?: 0
-            if (poll_ms < 0) throw badRequest("Param 'long_poll_ms' must be a 
positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
-            return PollingParams(poll_ms)
-        }
-    }
-}
-
-data class HistoryParams(
-    val page: PageParams, val polling: PollingParams
-) {
-    companion object {
-        fun extract(params: Parameters): HistoryParams {
-            return HistoryParams(PageParams.extract(params), 
PollingParams.extract(params))
-        }
-    }
-}
-
 data class RateParams(
     val debit: TalerAmount?, val credit: TalerAmount?
 ) {
diff --git a/bank/src/test/kotlin/AmountTest.kt 
b/bank/src/test/kotlin/AmountTest.kt
index 51c21b87..9f7b14aa 100644
--- a/bank/src/test/kotlin/AmountTest.kt
+++ b/bank/src/test/kotlin/AmountTest.kt
@@ -21,11 +21,8 @@ import org.junit.Test
 import tech.libeufin.bank.DecimalNumber
 import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult
 import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.TalerErrorCode
+import tech.libeufin.common.*
 import tech.libeufin.common.db.oneOrNull
-import tech.libeufin.common.json
-import tech.libeufin.common.obj
 import java.time.Instant
 import java.util.*
 import kotlin.test.assertEquals
diff --git a/bank/src/test/kotlin/CommonApiTest.kt 
b/bank/src/test/kotlin/CommonApiTest.kt
index 28866eea..a31483b6 100644
--- a/bank/src/test/kotlin/CommonApiTest.kt
+++ b/bank/src/test/kotlin/CommonApiTest.kt
@@ -20,7 +20,7 @@
 import io.ktor.client.request.*
 import io.ktor.http.*
 import org.junit.Test
-import tech.libeufin.common.TalerErrorCode
+import tech.libeufin.common.*
 
 class CommonApiTest {
     @Test
diff --git a/bank/src/test/kotlin/ConversionApiTest.kt 
b/bank/src/test/kotlin/ConversionApiTest.kt
index 9a8f2787..0954d3fa 100644
--- a/bank/src/test/kotlin/ConversionApiTest.kt
+++ b/bank/src/test/kotlin/ConversionApiTest.kt
@@ -20,8 +20,7 @@
 import io.ktor.client.request.*
 import org.junit.Test
 import tech.libeufin.bank.ConversionResponse
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.TalerErrorCode
+import tech.libeufin.common.*
 import kotlin.test.assertEquals
 
 class ConversionApiTest {
diff --git a/bank/src/test/kotlin/DatabaseTest.kt 
b/bank/src/test/kotlin/DatabaseTest.kt
index f7dbd82f..5f779544 100644
--- a/bank/src/test/kotlin/DatabaseTest.kt
+++ b/bank/src/test/kotlin/DatabaseTest.kt
@@ -23,6 +23,7 @@ import org.junit.Test
 import tech.libeufin.bank.createAdminAccount
 import tech.libeufin.bank.db.AccountDAO.AccountCreationResult
 import tech.libeufin.common.db.oneOrNull
+import tech.libeufin.common.*
 import java.time.Duration
 import java.time.Instant
 import java.time.temporal.ChronoUnit
diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt
index 06bb7498..5becfe10 100644
--- a/bank/src/test/kotlin/JsonTest.kt
+++ b/bank/src/test/kotlin/JsonTest.kt
@@ -23,7 +23,7 @@ import kotlinx.serialization.json.Json
 import org.junit.Test
 import tech.libeufin.bank.CreditDebitInfo
 import tech.libeufin.bank.RelativeTime
-import tech.libeufin.bank.TalerProtocolTimestamp
+import tech.libeufin.common.TalerProtocolTimestamp
 import tech.libeufin.common.TalerAmount
 import java.time.Duration
 import java.time.Instant
diff --git a/bank/src/test/kotlin/PaytoTest.kt 
b/bank/src/test/kotlin/PaytoTest.kt
index e75b8d9f..d8e09d01 100644
--- a/bank/src/test/kotlin/PaytoTest.kt
+++ b/bank/src/test/kotlin/PaytoTest.kt
@@ -22,9 +22,7 @@ import org.junit.Test
 import tech.libeufin.bank.BankAccountTransactionInfo
 import tech.libeufin.bank.RegisterAccountResponse
 import tech.libeufin.bank.TransactionCreateResponse
-import tech.libeufin.common.IbanPayto
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.json
+import tech.libeufin.common.*
 import kotlin.test.assertEquals
 
 class PaytoTest {
diff --git a/bank/src/test/kotlin/RevenueApiTest.kt 
b/bank/src/test/kotlin/RevenueApiTest.kt
index 2a458cd8..6c694842 100644
--- a/bank/src/test/kotlin/RevenueApiTest.kt
+++ b/bank/src/test/kotlin/RevenueApiTest.kt
@@ -20,6 +20,7 @@
 import io.ktor.http.*
 import org.junit.Test
 import tech.libeufin.bank.RevenueIncomingHistory
+import tech.libeufin.common.*
 
 class RevenueApiTest {
     // GET /accounts/{USERNAME}/taler-revenue/config
diff --git a/bank/src/test/kotlin/SecurityTest.kt 
b/bank/src/test/kotlin/SecurityTest.kt
index d9c4ca84..b74dc78d 100644
--- a/bank/src/test/kotlin/SecurityTest.kt
+++ b/bank/src/test/kotlin/SecurityTest.kt
@@ -21,10 +21,7 @@ import io.ktor.client.request.*
 import io.ktor.http.*
 import kotlinx.serialization.json.Json
 import org.junit.Test
-import tech.libeufin.common.TalerErrorCode
-import tech.libeufin.common.deflate
-import tech.libeufin.common.json
-import tech.libeufin.common.obj
+import tech.libeufin.common.*
 
 inline fun <reified B> HttpRequestBuilder.jsonDeflate(b: B) {
     val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b)
diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt 
b/bank/src/test/kotlin/WireGatewayApiTest.kt
index 82154663..1c6c60ef 100644
--- a/bank/src/test/kotlin/WireGatewayApiTest.kt
+++ b/bank/src/test/kotlin/WireGatewayApiTest.kt
@@ -19,8 +19,6 @@
 
 import io.ktor.http.*
 import org.junit.Test
-import tech.libeufin.bank.IncomingHistory
-import tech.libeufin.bank.OutgoingHistory
 import tech.libeufin.common.*
 
 class WireGatewayApiTest {
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
index 8c0f070b..73240c15 100644
--- a/bank/src/test/kotlin/helpers.kt
+++ b/bank/src/test/kotlin/helpers.kt
@@ -316,33 +316,6 @@ suspend fun tanCode(info: String): String? {
 
 /* ----- Assert ----- */
 
-suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: 
TalerErrorCode?): HttpResponse {
-    assertEquals(status, this.status, "$err")
-    if (err != null) {
-        val body = json<TalerError>()
-        assertEquals(err.code, body.code)
-    }
-    return this
-}
-suspend fun HttpResponse.assertOk(): HttpResponse
-    = assertStatus(HttpStatusCode.OK, null)
-suspend fun HttpResponse.assertNoContent(): HttpResponse 
-    = assertStatus(HttpStatusCode.NoContent, null)
-suspend fun HttpResponse.assertAccepted(): HttpResponse 
-    = assertStatus(HttpStatusCode.Accepted, null)
-suspend fun HttpResponse.assertNotFound(err: TalerErrorCode): HttpResponse 
-    = assertStatus(HttpStatusCode.NotFound, err)
-suspend fun HttpResponse.assertUnauthorized(err: TalerErrorCode = 
TalerErrorCode.GENERIC_UNAUTHORIZED): HttpResponse 
-    = assertStatus(HttpStatusCode.Unauthorized, err)
-suspend fun HttpResponse.assertConflict(err: TalerErrorCode): HttpResponse 
-    = assertStatus(HttpStatusCode.Conflict, err)
-suspend fun HttpResponse.assertBadRequest(err: TalerErrorCode = 
TalerErrorCode.GENERIC_JSON_INVALID): HttpResponse 
-    = assertStatus(HttpStatusCode.BadRequest, err)
-suspend fun HttpResponse.assertForbidden(err: TalerErrorCode): HttpResponse 
-    = assertStatus(HttpStatusCode.Forbidden, err)
-suspend fun HttpResponse.assertNotImplemented(err: TalerErrorCode = 
TalerErrorCode.END): HttpResponse 
-    = assertStatus(HttpStatusCode.NotImplemented, err)
-
 suspend fun HttpResponse.maybeChallenge(): HttpResponse {
     return if (this.status == HttpStatusCode.Accepted) {
         this.assertChallenge()
diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt
index c0c10c54..d49a3324 100644
--- a/bank/src/test/kotlin/routines.kt
+++ b/bank/src/test/kotlin/routines.kt
@@ -27,9 +27,7 @@ import kotlinx.coroutines.launch
 import kotlinx.serialization.json.JsonObject
 import tech.libeufin.bank.BankAccountCreateWithdrawalResponse
 import tech.libeufin.bank.WithdrawalStatus
-import tech.libeufin.common.TalerAmount
-import tech.libeufin.common.TalerErrorCode
-import tech.libeufin.common.json
+import tech.libeufin.common.*
 import kotlin.test.assertEquals
 
 // Test endpoint is correctly authenticated 
diff --git a/common/build.gradle b/common/build.gradle
index cc9649af..cdc9c3e0 100644
--- a/common/build.gradle
+++ b/common/build.gradle
@@ -24,10 +24,19 @@ dependencies {
     implementation("org.postgresql:postgresql:$postgres_version")
     implementation("com.zaxxer:HikariCP:5.1.0")
     
+    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-serialization-kotlinx-json:$ktor_version")
+    implementation("io.ktor:ktor-server-forwarded-header:$ktor_version")
     implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
     implementation("io.ktor:ktor-server-test-host:$ktor_version")
-    implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
+    
     implementation("com.github.ajalt.clikt:clikt:$clikt_version")
 
+    implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
     testImplementation("uk.org.webcompere:system-stubs-core:2.1.6")
 }
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt 
b/common/src/main/kotlin/ApiError.kt
similarity index 59%
copy from bank/src/main/kotlin/tech/libeufin/bank/Error.kt
copy to common/src/main/kotlin/ApiError.kt
index 18735633..15ae871c 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
+++ b/common/src/main/kotlin/ApiError.kt
@@ -1,6 +1,6 @@
 /*
  * This file is part of LibEuFin.
- * Copyright (C) 2023 Taler Systems S.A.
+ * Copyright (C) 2024 Taler Systems S.A.
 
  * LibEuFin is free software; you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License as
@@ -16,7 +16,7 @@
  * License along with LibEuFin; see the file COPYING.  If not, see
  * <http://www.gnu.org/licenses/>
  */
-package tech.libeufin.bank
+package tech.libeufin.common
 
 import io.ktor.http.*
 import io.ktor.server.application.*
@@ -27,11 +27,11 @@ import tech.libeufin.common.TalerAmount
 import tech.libeufin.common.TalerErrorCode
 
 /**
- * Convenience type to throw errors along the bank activity
+ * Convenience type to throw errors along the API activity
  * and that is meant to be caught by Ktor and responded to the
  * client.
  */
-class LibeufinException(
+class ApiException(
     // Status code that Ktor will set for the response.
     val httpStatus: HttpStatusCode,
     // Error detail object, after Taler API.
@@ -62,7 +62,7 @@ suspend fun ApplicationCall.err(
     error: TalerErrorCode
 ) {
     err(
-        LibeufinException(
+        ApiException(
             httpStatus = status, talerError = TalerError(
                 code = error.code, err = error, hint = hint
             )
@@ -71,7 +71,7 @@ suspend fun ApplicationCall.err(
 }
 
 suspend fun ApplicationCall.err(
-    err: LibeufinException
+    err: ApiException
 ) {
     attributes.put(LOG_MSG, "${err.talerError.err.name} 
${err.talerError.hint}")
     respond(
@@ -81,12 +81,12 @@ suspend fun ApplicationCall.err(
 }
 
 
-fun libeufinError(
+fun apiError(
     status: HttpStatusCode,
     hint: String?,
     error: TalerErrorCode,
     detail: String? = null
-): LibeufinException = LibeufinException(
+): ApiException = ApiException(
     httpStatus = status, talerError = TalerError(
         code = error.code, err = error, hint = hint, detail = detail
     )
@@ -97,72 +97,32 @@ fun libeufinError(
 fun forbidden(
     hint: String = "No rights on the resource",
     error: TalerErrorCode = TalerErrorCode.END
-): LibeufinException = libeufinError(HttpStatusCode.Forbidden, hint, error)
+): ApiException = apiError(HttpStatusCode.Forbidden, hint, error)
 
 fun unauthorized(
     hint: String,
     error: TalerErrorCode = TalerErrorCode.GENERIC_UNAUTHORIZED
-): LibeufinException = libeufinError(HttpStatusCode.Unauthorized, hint, error)
+): ApiException = apiError(HttpStatusCode.Unauthorized, hint, error)
 
-fun internalServerError(hint: String?): LibeufinException 
-    = libeufinError(HttpStatusCode.InternalServerError, hint, 
TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE)
+fun internalServerError(hint: String?): ApiException 
+    = apiError(HttpStatusCode.InternalServerError, hint, 
TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE)
 
 fun notFound(
     hint: String,
     error: TalerErrorCode
-): LibeufinException = libeufinError(HttpStatusCode.NotFound, hint, error)
+): ApiException = apiError(HttpStatusCode.NotFound, hint, error)
 
 fun conflict(
     hint: String, error: TalerErrorCode
-): LibeufinException = libeufinError(HttpStatusCode.Conflict, hint, error)
+): ApiException = apiError(HttpStatusCode.Conflict, hint, error)
 
 fun badRequest(
     hint: String? = null, 
     error: TalerErrorCode = TalerErrorCode.GENERIC_JSON_INVALID,
     detail: String? = null
-): LibeufinException = libeufinError(HttpStatusCode.BadRequest, hint, error, 
detail)
+): ApiException = apiError(HttpStatusCode.BadRequest, hint, error, detail)
 
 fun unsupportedMediaType(
     hint: String, 
     error: TalerErrorCode = TalerErrorCode.END,
-): LibeufinException = libeufinError(HttpStatusCode.UnsupportedMediaType, 
hint, error)
-
-
-/* ----- Currency checks ----- */
-
-fun BankConfig.checkRegionalCurrency(amount: TalerAmount) {
-    if (amount.currency != regionalCurrency) throw badRequest(
-        "Wrong currency: expected regional currency $regionalCurrency got 
${amount.currency}",
-        TalerErrorCode.GENERIC_CURRENCY_MISMATCH
-    )
-}
-
-fun BankConfig.checkFiatCurrency(amount: TalerAmount) {
-    if (amount.currency != fiatCurrency) throw badRequest(
-        "Wrong currency: expected fiat currency $fiatCurrency got 
${amount.currency}",
-        TalerErrorCode.GENERIC_CURRENCY_MISMATCH
-    )
-}
-
-/* ----- Common errors ----- */
-
-fun unknownAccount(id: String): LibeufinException {
-    return notFound(
-        "Account '$id' not found",
-        TalerErrorCode.BANK_UNKNOWN_ACCOUNT
-    )
-}
-
-fun unknownCreditorAccount(id: String): LibeufinException {
-    return conflict(
-        "Creditor account '$id' not found",
-        TalerErrorCode.BANK_UNKNOWN_CREDITOR
-    )
-}
-
-fun unsupportedTanChannel(channel: TanChannel): LibeufinException {
-    return conflict(
-        "Unsupported tan channel $channel",
-        TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED
-    )
-}
\ No newline at end of file
+): ApiException = apiError(HttpStatusCode.UnsupportedMediaType, hint, error)
\ No newline at end of file
diff --git a/common/src/main/kotlin/Client.kt b/common/src/main/kotlin/Client.kt
index 6219071d..a3fc8c00 100644
--- a/common/src/main/kotlin/Client.kt
+++ b/common/src/main/kotlin/Client.kt
@@ -67,4 +67,33 @@ suspend inline fun <reified B> 
HttpResponse.assertOkJson(lambda: (B) -> Unit = {
     val body = json<B>()
     lambda(body)
     return body
-}
\ No newline at end of file
+}
+
+/* ----- Assert ----- */
+
+suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: 
TalerErrorCode?): HttpResponse {
+    assertEquals(status, this.status, "$err")
+    if (err != null) {
+        val body = json<TalerError>()
+        assertEquals(err.code, body.code)
+    }
+    return this
+}
+suspend fun HttpResponse.assertOk(): HttpResponse
+    = assertStatus(HttpStatusCode.OK, null)
+suspend fun HttpResponse.assertNoContent(): HttpResponse 
+    = assertStatus(HttpStatusCode.NoContent, null)
+suspend fun HttpResponse.assertAccepted(): HttpResponse 
+    = assertStatus(HttpStatusCode.Accepted, null)
+suspend fun HttpResponse.assertNotFound(err: TalerErrorCode): HttpResponse 
+    = assertStatus(HttpStatusCode.NotFound, err)
+suspend fun HttpResponse.assertUnauthorized(err: TalerErrorCode = 
TalerErrorCode.GENERIC_UNAUTHORIZED): HttpResponse 
+    = assertStatus(HttpStatusCode.Unauthorized, err)
+suspend fun HttpResponse.assertConflict(err: TalerErrorCode): HttpResponse 
+    = assertStatus(HttpStatusCode.Conflict, err)
+suspend fun HttpResponse.assertBadRequest(err: TalerErrorCode = 
TalerErrorCode.GENERIC_JSON_INVALID): HttpResponse 
+    = assertStatus(HttpStatusCode.BadRequest, err)
+suspend fun HttpResponse.assertForbidden(err: TalerErrorCode): HttpResponse 
+    = assertStatus(HttpStatusCode.Forbidden, err)
+suspend fun HttpResponse.assertNotImplemented(err: TalerErrorCode = 
TalerErrorCode.END): HttpResponse 
+    = assertStatus(HttpStatusCode.NotImplemented, err)
diff --git a/common/src/main/kotlin/Constants.kt 
b/common/src/main/kotlin/Constants.kt
index 7cc6ab0b..0e48cf1e 100644
--- a/common/src/main/kotlin/Constants.kt
+++ b/common/src/main/kotlin/Constants.kt
@@ -20,4 +20,10 @@ package tech.libeufin.common
 
 // DB
 const val MIN_VERSION: Int = 14
-const val SERIALIZATION_RETRY: Int = 10
\ No newline at end of file
+const val SERIALIZATION_RETRY: Int = 10
+
+// Security
+const val MAX_BODY_LENGTH: Long = 4 * 1024 // 4kB
+
+// API version
+const val WIRE_GATEWAY_API_VERSION: String = "0:2:0"
\ No newline at end of file
diff --git a/common/src/main/kotlin/TalerCommon.kt 
b/common/src/main/kotlin/TalerCommon.kt
index 7d561c5c..676bb5a1 100644
--- a/common/src/main/kotlin/TalerCommon.kt
+++ b/common/src/main/kotlin/TalerCommon.kt
@@ -27,7 +27,17 @@ import 
kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
 import kotlinx.serialization.descriptors.SerialDescriptor
 import kotlinx.serialization.encoding.Decoder
 import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import tech.libeufin.common.*
 import java.net.URI
+import java.net.URL
+import java.time.Duration
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+import java.util.concurrent.TimeUnit
 
 sealed class CommonError(msg: String): Exception(msg) {
     class AmountFormat(msg: String): CommonError(msg)
@@ -35,6 +45,76 @@ sealed class CommonError(msg: String): Exception(msg) {
     class Payto(msg: String): CommonError(msg)
 }
 
+
+/** Timestamp containing the number of seconds since epoch */
+@Serializable
+data class TalerProtocolTimestamp(
+    @Serializable(with = Serializer::class)
+    val t_s: Instant,
+) {
+    companion object {
+        fun fromMicroseconds(uSec: Long): TalerProtocolTimestamp {
+            return TalerProtocolTimestamp(
+                Instant.EPOCH.plus(uSec, ChronoUnit.MICROS)
+            )
+        }
+    }
+
+    internal object Serializer : KSerializer<Instant> {
+        override fun serialize(encoder: Encoder, value: Instant) {
+            if (value == Instant.MAX) {
+                encoder.encodeString("never")
+            } else {
+                encoder.encodeLong(value.epochSecond)
+            }
+            
+        }
+    
+        override fun deserialize(decoder: Decoder): Instant {
+            val jsonInput = decoder as? JsonDecoder ?: error("Can be 
deserialized only by JSON")
+            val maybeTs = jsonInput.decodeJsonElement().jsonPrimitive
+            if (maybeTs.isString) {
+                if (maybeTs.content != "never") throw badRequest("Only 'never' 
allowed for t_s as string, but '${maybeTs.content}' was found")
+                return Instant.MAX
+            }
+            val ts: Long = maybeTs.longOrNull
+                ?: throw badRequest("Could not convert t_s 
'${maybeTs.content}' to a number")
+            when {
+                ts < 0 -> throw badRequest("Negative timestamp not allowed")
+                ts > Instant.MAX.epochSecond -> throw badRequest("Timestamp 
$ts too big to be represented in Kotlin")
+                else -> return Instant.ofEpochSecond(ts)
+            }
+        }
+    
+        override val descriptor: SerialDescriptor = 
JsonElement.serializer().descriptor
+    }
+}
+
+
+@Serializable(with = ExchangeUrl.Serializer::class)
+class ExchangeUrl {
+    val url: String
+
+    constructor(raw: String) {
+        url = URL(raw).toString()
+    }
+
+    override fun toString(): String = url
+
+    internal object Serializer : KSerializer<ExchangeUrl> {
+        override val descriptor: SerialDescriptor =
+                PrimitiveSerialDescriptor("ExchangeUrl", PrimitiveKind.STRING)
+
+        override fun serialize(encoder: Encoder, value: ExchangeUrl) {
+            encoder.encodeString(value.toString())
+        }
+
+        override fun deserialize(decoder: Decoder): ExchangeUrl {
+            return ExchangeUrl(decoder.decodeString())
+        }
+    }
+}
+
 @Serializable(with = TalerAmount.Serializer::class)
 class TalerAmount {
     val value: Long
diff --git a/common/src/main/kotlin/TalerMessage.kt 
b/common/src/main/kotlin/TalerMessage.kt
new file mode 100644
index 00000000..6610376f
--- /dev/null
+++ b/common/src/main/kotlin/TalerMessage.kt
@@ -0,0 +1,103 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+ *
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+ *
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING.  If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+/** Response GET /taler-wire-gateway/config */
+@Serializable
+data class WireGatewayConfig(
+    val currency: String
+) {
+    val name: String = "taler-wire-gateway"
+    val version: String = WIRE_GATEWAY_API_VERSION
+}
+
+/** Request POST /taler-wire-gateway/transfer */
+@Serializable
+data class TransferRequest(
+    val request_uid: HashCode,
+    val amount: TalerAmount,
+    val exchange_base_url: ExchangeUrl,
+    val wtid: ShortHashCode,
+    val credit_account: Payto
+)
+
+/** Response POST /taler-wire-gateway/transfer */
+@Serializable
+data class TransferResponse(
+    val timestamp: TalerProtocolTimestamp,
+    val row_id: Long
+)
+
+/** Request POST /taler-wire-gateway/admin/add-incoming */
+@Serializable
+data class AddIncomingRequest(
+    val amount: TalerAmount,
+    val reserve_pub: EddsaPublicKey,
+    val debit_account: Payto
+)
+
+/** Response POST /taler-wire-gateway/admin/add-incoming */
+@Serializable
+data class AddIncomingResponse(
+    val timestamp: TalerProtocolTimestamp,
+    val row_id: Long
+)
+
+/** Request GET /taler-wire-gateway/history/incoming */
+@Serializable
+data class IncomingHistory(
+    val incoming_transactions: List<IncomingReserveTransaction>,
+    val credit_account: String
+)
+
+@Serializable
+data class IncomingReserveTransaction(
+    val type: String = "RESERVE",
+    val row_id: Long, // DB row ID of the payment.
+    val date: TalerProtocolTimestamp,
+    val amount: TalerAmount,
+    val debit_account: String,
+    val reserve_pub: EddsaPublicKey
+)
+
+/** Request GET /taler-wire-gateway/history/outgoing */
+@Serializable
+data class OutgoingHistory(
+    val outgoing_transactions: List<OutgoingTransaction>,
+    val debit_account: String
+)
+
+@Serializable
+data class OutgoingTransaction(
+    val row_id: Long, // DB row ID of the payment.
+    val date: TalerProtocolTimestamp,
+    val amount: TalerAmount,
+    val credit_account: String,
+    val wtid: ShortHashCode,
+    val exchange_base_url: String,
+)
diff --git a/common/src/main/kotlin/api/server.kt 
b/common/src/main/kotlin/api/server.kt
new file mode 100644
index 00000000..ba6f2f61
--- /dev/null
+++ b/common/src/main/kotlin/api/server.kt
@@ -0,0 +1,225 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING.  If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common.api
+
+import io.ktor.http.*
+import io.ktor.serialization.kotlinx.json.*
+import io.ktor.server.application.*
+import io.ktor.server.engine.*
+import io.ktor.server.http.content.*
+import io.ktor.server.netty.*
+import io.ktor.server.plugins.*
+import io.ktor.server.plugins.callloging.*
+import io.ktor.server.plugins.contentnegotiation.*
+import io.ktor.server.plugins.cors.routing.*
+import io.ktor.server.plugins.forwardedheaders.*
+import io.ktor.server.plugins.statuspages.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import io.ktor.utils.io.*
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import org.postgresql.util.PSQLState
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import org.slf4j.event.Level
+import tech.libeufin.common.*
+import tech.libeufin.common.db.dbInit
+import tech.libeufin.common.db.pgDataSource
+import java.net.InetAddress
+import java.sql.SQLException
+import java.time.Instant
+import java.util.zip.DataFormatException
+import java.util.zip.Inflater
+import kotlin.io.path.Path
+import kotlin.io.path.exists
+import kotlin.io.path.readText
+
+/**
+ * This plugin checks for body length limit and inflates the requests that 
have "Content-Encoding: deflate"
+ */
+fun bodyLimitPlugin(logger: Logger): ApplicationPlugin<Unit> {
+    return createApplicationPlugin("BodyLimitAndDecompression") {
+        onCallReceive { call ->
+            // TODO check content length as an optimisation
+            transformBody { body ->
+                val bytes = ByteArray(MAX_BODY_LENGTH.toInt() + 1)
+                var read = 0
+                when (val encoding = 
call.request.headers[HttpHeaders.ContentEncoding])  {
+                    "deflate" -> {
+                        // Decompress and check decompressed length
+                        val inflater = Inflater()
+                        while (!body.isClosedForRead) {
+                            body.read { buf ->
+                                inflater.setInput(buf)
+                                try {
+                                    read += inflater.inflate(bytes, read, 
bytes.size - read)
+                                } catch (e: DataFormatException) {
+                                    logger.error("Deflated request failed to 
inflate: ${e.message}")
+                                    throw badRequest(
+                                        "Could not inflate request",
+                                        
TalerErrorCode.GENERIC_COMPRESSION_INVALID
+                                    )
+                                }
+                            }
+                            if (read > MAX_BODY_LENGTH)
+                                throw badRequest("Decompressed body is 
suspiciously big > $MAX_BODY_LENGTH B")
+                        }
+                    }
+                    null -> {
+                        // Check body length
+                        while (true) {
+                            val new = body.readAvailable(bytes, read, 
bytes.size - read)
+                            if (new == -1) break // Channel is closed
+                            read += new
+                            if (read > MAX_BODY_LENGTH)
+                                throw badRequest("Body is suspiciously big > 
$MAX_BODY_LENGTH B")
+                        }
+                    } 
+                    else -> throw unsupportedMediaType(
+                        "Content encoding '$encoding' not supported, expected 
plain or deflate",
+                        TalerErrorCode.GENERIC_COMPRESSION_INVALID
+                    )
+                }
+                ByteReadChannel(bytes, 0, read)
+            }
+        }
+    }
+}
+
+/** Set up web server handlers for a Taler API */
+fun Application.talerApi(logger: Logger, routes: Routing.() -> Unit) {
+    install(CallLogging) {
+        level = Level.INFO
+        this.logger = logger
+        format { call ->
+            val status = call.response.status()
+            val httpMethod = call.request.httpMethod.value
+            val path = call.request.path()
+            val msg = call.logMsg()
+            if (msg != null) {
+                "$status, $httpMethod $path, $msg"
+            } else {
+                "$status, $httpMethod $path"
+            }
+        }
+    }
+    install(XForwardedHeaders)
+    install(CORS) {
+        anyHost()
+        allowHeader(HttpHeaders.Authorization)
+        allowHeader(HttpHeaders.ContentType)
+        allowMethod(HttpMethod.Options)
+        allowMethod(HttpMethod.Patch)
+        allowMethod(HttpMethod.Delete)
+        allowCredentials = true
+    }
+    install(bodyLimitPlugin(logger))
+    install(IgnoreTrailingSlash)
+    install(ContentNegotiation) {
+        json(Json {
+            @OptIn(ExperimentalSerializationApi::class)
+            explicitNulls = false
+            encodeDefaults = true
+            ignoreUnknownKeys = true
+        })
+    }
+    install(StatusPages) {
+        status(HttpStatusCode.NotFound) { call, status ->
+            call.err(
+                status,
+                "There is no endpoint defined for the URL provided by the 
client. Check if you used the correct URL and/or file a report with the 
developers of the client software.",
+                TalerErrorCode.GENERIC_ENDPOINT_UNKNOWN
+            )
+        }
+        status(HttpStatusCode.MethodNotAllowed) { call, status ->
+            call.err(
+                status,
+                "The HTTP method used is invalid for this endpoint. This is 
likely a bug in the client implementation. Check if you are using the latest 
available version and/or file a report with the developers.",
+                TalerErrorCode.GENERIC_METHOD_INVALID
+            )
+        }
+        exception<Exception> { call, cause ->
+            logger.debug("request failed", cause)
+            // TODO nexus specific error code ?!
+            when (cause) {
+                is ApiException -> call.err(cause)
+                is SQLException -> {
+                    when (cause.sqlState) {
+                        PSQLState.SERIALIZATION_FAILURE.state -> call.err(
+                            HttpStatusCode.InternalServerError,
+                            "Transaction serialization failure",
+                            TalerErrorCode.BANK_SOFT_EXCEPTION
+                        )
+                        else -> call.err(
+                            HttpStatusCode.InternalServerError,
+                            "Unexpected sql error with state 
${cause.sqlState}",
+                            TalerErrorCode.BANK_UNMANAGED_EXCEPTION
+                        )
+                    }
+                }
+                is BadRequestException -> {
+                    /**
+                     * NOTE: extracting the root cause helps with JSON error 
messages,
+                     * because they mention the particular way they are 
invalid, but OTOH
+                     * it loses (by getting null) other error messages, like 
for example
+                     * the one from MissingRequestParameterException.  
Therefore, in order
+                     * to get the most detailed message, we must consider BOTH 
sides:
+                     * the 'cause' AND its root cause!
+                     */
+                    var rootCause: Throwable? = cause.cause
+                    while (rootCause?.cause != null)
+                        rootCause = rootCause.cause
+                    // Telling apart invalid JSON vs missing parameter vs 
invalid parameter.
+                    val talerErrorCode = when {
+                        cause is MissingRequestParameterException ->
+                            TalerErrorCode.GENERIC_PARAMETER_MISSING
+                        cause is ParameterConversionException ->
+                            TalerErrorCode.GENERIC_PARAMETER_MALFORMED
+                        rootCause is CommonError -> when (rootCause) {
+                            is CommonError.AmountFormat -> 
TalerErrorCode.BANK_BAD_FORMAT_AMOUNT
+                            is CommonError.AmountNumberTooBig -> 
TalerErrorCode.BANK_NUMBER_TOO_BIG
+                            is CommonError.Payto -> 
TalerErrorCode.GENERIC_JSON_INVALID
+                        }
+                        else -> TalerErrorCode.GENERIC_JSON_INVALID
+                    }
+                    call.err(
+                        badRequest(
+                            cause.message,
+                            talerErrorCode,
+                            /* Here getting _some_ error message, by giving 
precedence
+                            * to the root cause, as otherwise JSON details 
would be lost. */
+                            rootCause?.message
+                        )
+                    )
+                }
+                else -> {
+                    call.err(
+                        HttpStatusCode.InternalServerError,
+                        cause.message,
+                        TalerErrorCode.BANK_UNMANAGED_EXCEPTION
+                    )
+                }
+            }
+        }
+    }
+    routing { routes() }
+}
\ No newline at end of file
diff --git a/common/src/main/kotlin/db/helpers.kt 
b/common/src/main/kotlin/db/helpers.kt
new file mode 100644
index 00000000..13e0ace0
--- /dev/null
+++ b/common/src/main/kotlin/db/helpers.kt
@@ -0,0 +1,113 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+ *
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+ *
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING.  If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common.db
+
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.*
+import tech.libeufin.common.db.DbPool
+import tech.libeufin.common.PageParams
+import tech.libeufin.common.HistoryParams
+import org.postgresql.jdbc.PgConnection
+import org.postgresql.util.PSQLState
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.sql.PreparedStatement
+import java.sql.ResultSet
+import java.sql.SQLException
+import kotlin.math.abs
+
+/** Apply paging logic to a sql query */
+suspend fun <T> DbPool.page(
+    params: PageParams,
+    idName: String,
+    query: String,
+    bind: PreparedStatement.() -> Int = { 0 },
+    map: (ResultSet) -> T
+): List<T> = conn { conn ->
+    val backward = params.delta < 0
+    val pageQuery = """
+        $query
+        $idName ${if (backward) '<' else '>'} ?
+        ORDER BY $idName ${if (backward) "DESC" else "ASC"}
+        LIMIT ?
+    """
+    conn.prepareStatement(pageQuery).run {
+        val pad = bind()
+        setLong(pad + 1, params.start)
+        setInt(pad + 2, abs(params.delta))
+        all { map(it) }
+    }
+}
+
+/**
+* The following function returns the list of transactions, according
+* to the history parameters and perform long polling when necessary
+*/
+suspend fun <T> DbPool.poolHistory(
+    params: HistoryParams, 
+    bankAccountId: Long,
+    listen: suspend (Long, suspend (Flow<Long>) -> List<T>) -> List<T>,
+    query: String,
+    accountColumn: String = "bank_account_id",
+    map: (ResultSet) -> T
+): List<T> {
+
+    suspend fun load(): List<T> = page(
+        params.page, 
+        "bank_transaction_id", 
+        "$query $accountColumn=? AND", 
+        {
+            setLong(1, bankAccountId)
+            1
+        },
+        map
+    )
+        
+
+    // TODO do we want to handle polling when going backward and there is no 
transactions yet ?
+    // When going backward there is always at least one transaction or none
+    return if (params.page.delta >= 0 && params.polling.poll_ms > 0) {
+        listen(bankAccountId) { flow ->
+            coroutineScope {
+                // Start buffering notification before loading transactions to 
not miss any
+                val polling = launch {
+                    withTimeoutOrNull(params.polling.poll_ms) {
+                        flow.first { it > params.page.start } // Always 
forward so >
+                    }
+                }    
+                // Initial loading
+                val init = load()
+                // Long polling if we found no transactions
+                if (init.isEmpty()) {
+                    if (polling.join() != null) {
+                        load()
+                    } else {
+                        init
+                    }
+                } else {
+                    polling.cancel()
+                    init
+                }
+            }
+        }
+    } else {
+        load()
+    }
+}
\ No newline at end of file
diff --git a/common/src/main/kotlin/db/notifications.kt 
b/common/src/main/kotlin/db/notifications.kt
new file mode 100644
index 00000000..3f2fb753
--- /dev/null
+++ b/common/src/main/kotlin/db/notifications.kt
@@ -0,0 +1,91 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+ *
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+ *
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING.  If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common.db
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import org.postgresql.ds.PGSimpleDataSource
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import tech.libeufin.common.*
+import tech.libeufin.common.db.*
+import java.util.*
+import java.util.concurrent.ConcurrentHashMap
+
+// SharedFlow that are manually counted for manual garbage collection
+class CountedSharedFlow<T>(val flow: MutableSharedFlow<T>, var count: Int)
+
+fun watchNotifications(
+    pgSource: PGSimpleDataSource, 
+    schema: String,
+    logger: Logger,
+    listeners: Map<String, (suspend (String) -> Unit)>
+) {
+    val backoff = ExpoBackoffDecorr()
+    // Run notification logic in a separated thread
+    kotlin.concurrent.thread(isDaemon = true) {
+        runBlocking {
+            while (true) {
+                try {
+                    val conn = pgSource.pgConnection(schema)
+
+                    // Listen to all notifications channels
+                    for (channel in listeners.keys) {
+                        conn.execSQLUpdate("LISTEN $channel")
+                    }
+
+                    backoff.reset()
+
+                    while (true) {
+                        conn.getNotifications(0) // Block until we receive at 
least one notification
+                            .forEach {
+                            // Dispatch
+                            listeners[it.name]!!(it.parameter)
+                        }
+                    }
+                } catch (e: Exception) {
+                    e.fmtLog(logger)
+                    delay(backoff.next())
+                }
+            }
+        }
+    }
+}
+
+/** Listen to flow from [map] for [key] using [lambda]*/
+suspend fun <R, K, V> listen(map: ConcurrentHashMap<K, CountedSharedFlow<V>>, 
key: K, lambda: suspend (Flow<V>) -> R): R {
+    // Register listener, create a new flow if missing
+    val flow = map.compute(key) { _, v ->
+        val tmp = v ?: CountedSharedFlow(MutableSharedFlow(), 0)
+        tmp.count++
+        tmp
+    }!!.flow
+
+    try {
+        return lambda(flow)
+    } finally {
+        // Unregister listener, removing unused flow
+        map.compute(key) { _, v ->
+            v!!
+            v.count--
+            if (v.count > 0) v else null
+        }
+    }
+}
\ No newline at end of file
diff --git a/common/src/main/kotlin/db/utils.kt 
b/common/src/main/kotlin/db/transaction.kt
similarity index 100%
rename from common/src/main/kotlin/db/utils.kt
rename to common/src/main/kotlin/db/transaction.kt
diff --git a/common/src/main/kotlin/params.kt b/common/src/main/kotlin/params.kt
new file mode 100644
index 00000000..a24bd379
--- /dev/null
+++ b/common/src/main/kotlin/params.kt
@@ -0,0 +1,95 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING.  If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.common
+
+import io.ktor.http.*
+import tech.libeufin.common.TalerAmount
+import tech.libeufin.common.TalerErrorCode
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.ZoneOffset
+import java.time.temporal.TemporalAdjusters
+import java.util.*
+
+fun Parameters.expect(name: String): String 
+    = get(name) ?: throw badRequest("Missing '$name' parameter", 
TalerErrorCode.GENERIC_PARAMETER_MISSING)
+fun Parameters.int(name: String): Int? 
+    = get(name)?.run { toIntOrNull() ?: throw badRequest("Param '$name' not a 
number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) }
+fun Parameters.expectInt(name: String): Int 
+    = int(name) ?: throw badRequest("Missing '$name' number parameter", 
TalerErrorCode.GENERIC_PARAMETER_MISSING)
+fun Parameters.long(name: String): Long? 
+    = get(name)?.run { toLongOrNull() ?: throw badRequest("Param '$name' not a 
number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED) }
+fun Parameters.expectLong(name: String): Long 
+    = long(name) ?: throw badRequest("Missing '$name' number parameter", 
TalerErrorCode.GENERIC_PARAMETER_MISSING)
+fun Parameters.uuid(name: String): UUID? {
+    return get(name)?.run {
+        try {
+            UUID.fromString(this)
+        } catch (e: Exception) {
+            throw badRequest("Param '$name' not an UUID", 
TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+        }
+    } 
+}
+fun Parameters.expectUuid(name: String): UUID 
+    = uuid(name) ?: throw badRequest("Missing '$name' UUID parameter", 
TalerErrorCode.GENERIC_PARAMETER_MISSING)
+fun Parameters.amount(name: String): TalerAmount? 
+    = get(name)?.run { 
+        try {
+            TalerAmount(this)
+        } catch (e: Exception) {
+            throw badRequest("Param '$name' not a taler amount", 
TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+        }
+    }
+
+data class PageParams(
+    val delta: Int, val start: Long
+) {
+    companion object {
+        fun extract(params: Parameters): PageParams {
+            val delta: Int = params.int("delta") ?: -20
+            val start: Long = params.long("start") ?: if (delta >= 0) 0L else 
Long.MAX_VALUE
+            if (start < 0) throw badRequest("Param 'start' must be a positive 
number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+            // TODO enforce delta limit
+            return PageParams(delta, start)
+        }
+    }
+}
+
+data class PollingParams(
+    val poll_ms: Long
+) {
+    companion object {
+        fun extract(params: Parameters): PollingParams {
+            val poll_ms: Long = params.long("long_poll_ms") ?: 0
+            if (poll_ms < 0) throw badRequest("Param 'long_poll_ms' must be a 
positive number", TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
+            return PollingParams(poll_ms)
+        }
+    }
+}
+
+data class HistoryParams(
+    val page: PageParams, val polling: PollingParams
+) {
+    companion object {
+        fun extract(params: Parameters): HistoryParams {
+            return HistoryParams(PageParams.extract(params), 
PollingParams.extract(params))
+        }
+    }
+}
\ No newline at end of file
diff --git a/common/src/test/kotlin/AmountTest.kt 
b/common/src/test/kotlin/AmountTest.kt
index 78fc2ba8..c80da6de 100644
--- a/common/src/test/kotlin/AmountTest.kt
+++ b/common/src/test/kotlin/AmountTest.kt
@@ -18,7 +18,7 @@
  */
 
 import org.junit.Test
-import tech.libeufin.common.TalerAmount
+import tech.libeufin.common.*
 import kotlin.test.assertEquals
 
 class AmountTest {
diff --git a/contrib/nexus.conf b/contrib/nexus.conf
index b6403eac..c240df5f 100644
--- a/contrib/nexus.conf
+++ b/contrib/nexus.conf
@@ -57,15 +57,14 @@ FREQUENCY = 30m
 
 [nexus-httpd]
 PORT = 8080
-UNIXPATH =
-SERVE = tcp | unix
+SERVE = tcp
 
-[nexus-httpd-wire-gateway-facade]
-ENABLED = YES
+[nexus-httpd-wire-gateway-api]
+ENABLED = NO
 AUTH_METHOD = token
 AUTH_TOKEN =
 
-[nexus-httpd-revenue-facade]
-ENABLED = YES
+[nexus-httpd-revenue-api]
+ENABLED = NO
 AUTH_METHOD = token
 AUTH_TOKEN =
diff --git a/database-versioning/libeufin-nexus-procedures.sql 
b/database-versioning/libeufin-nexus-procedures.sql
index b941a739..8e50916f 100644
--- a/database-versioning/libeufin-nexus-procedures.sql
+++ b/database-versioning/libeufin-nexus-procedures.sql
@@ -205,17 +205,31 @@ CREATE FUNCTION register_incoming_and_talerable(
   ,IN in_debit_payto_uri TEXT
   ,IN in_bank_id TEXT
   ,IN in_reserve_public_key BYTEA
+  -- Error status
+  ,OUT out_reserve_pub_reuse BOOLEAN
+  -- Success return
   ,OUT out_found BOOLEAN
   ,OUT out_tx_id INT8
 )
 LANGUAGE plpgsql AS $$
 BEGIN
+-- Check conflict
+IF EXISTS (
+  SELECT FROM talerable_incoming_transactions 
+  JOIN incoming_transactions ON 
talerable_incoming_transactions.incoming_transaction_id=incoming_transactions.incoming_transaction_id
+  WHERE reserve_public_key = in_reserve_public_key
+  AND bank_id != in_bank_id
+) THEN
+  out_reserve_pub_reuse = TRUE;
+  RETURN;
+END IF;
+
 -- Register the incoming transaction
 SELECT reg.out_found, reg.out_tx_id
   FROM register_incoming(in_amount, in_wire_transfer_subject, 
in_execution_time, in_debit_payto_uri, in_bank_id) as reg
   INTO out_found, out_tx_id;
 
--- Register as talerable bounce
+-- Register as talerable
 IF NOT EXISTS(SELECT 1 FROM talerable_incoming_transactions WHERE 
incoming_transaction_id = out_tx_id) THEN
   -- We cannot use ON CONFLICT here because conversion use a trigger before 
insertion that isn't idempotent
   INSERT INTO talerable_incoming_transactions (
diff --git a/nexus/build.gradle b/nexus/build.gradle
index 837e46f0..de8b8648 100644
--- a/nexus/build.gradle
+++ b/nexus/build.gradle
@@ -27,6 +27,7 @@ dependencies {
     implementation("com.github.ajalt.clikt:clikt:$clikt_version")
     implementation("org.postgresql:postgresql:$postgres_version")
     // Ktor client library
+    implementation("io.ktor:ktor-server-core:$ktor_version")
     implementation("io.ktor:ktor-client-cio:$ktor_version")
 
     // PDF generation
@@ -40,6 +41,7 @@ dependencies {
 
     // Unit testing
     testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
+    testImplementation("io.ktor:ktor-server-test-host:$ktor_version")
     testImplementation("io.ktor:ktor-client-mock:$ktor_version")
 }
 
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
index 610b2c8b..4cd88fbf 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -28,6 +28,7 @@ import io.ktor.client.plugins.*
 import kotlinx.coroutines.*
 import tech.libeufin.common.*
 import tech.libeufin.nexus.db.*
+import tech.libeufin.nexus.db.PaymentDAO.*
 import tech.libeufin.nexus.ebics.*
 import java.io.IOException
 import java.io.InputStream
@@ -120,11 +121,16 @@ suspend fun ingestIncomingPayment(
 ) {
     runCatching { parseIncomingTxMetadata(payment.wireTransferSubject) }.fold(
         onSuccess = { reservePub -> 
-            val result = db.payment.registerTalerableIncoming(payment, 
reservePub)
-            if (result.new) {
-                logger.info("$payment")
-            } else {
-                logger.debug("$payment already seen")
+            val res = db.payment.registerTalerableIncoming(payment, reservePub)
+            when (res) {
+                IncomingRegistrationResult.ReservePubReuse -> throw 
Error("TODO reserve pub reuse")
+                is IncomingRegistrationResult.Success -> {
+                    if (res.new) {
+                        logger.info("$payment")
+                    } else {
+                        logger.debug("$payment already seen")
+                    }
+                }
             }
         },
         onFailure = { e ->
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
index 08bb23f1..41cb58b0 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -33,10 +33,13 @@ import 
com.github.ajalt.clikt.parameters.groups.provideDelegate
 import com.github.ajalt.clikt.parameters.options.convert
 import com.github.ajalt.clikt.parameters.options.option
 import com.github.ajalt.clikt.parameters.options.versionOption
+import io.ktor.server.application.*
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
 import tech.libeufin.common.*
+import tech.libeufin.common.api.*
 import tech.libeufin.common.db.DatabaseConfig
+import tech.libeufin.nexus.api.*
 import tech.libeufin.nexus.db.Database
 import tech.libeufin.nexus.db.InitiatedPayment
 import java.nio.file.Path
@@ -105,6 +108,16 @@ class NexusConfig(val config: TalerConfig) {
     val fetch = NexusFetchConfig(config)
 }
 
+fun NexusConfig.checkCurrency(amount: TalerAmount) {
+    if (amount.currency != currency) throw badRequest(
+        "Wrong currency: expected regional $currency got ${amount.currency}",
+        TalerErrorCode.GENERIC_CURRENCY_MISMATCH
+    )
+}
+
+fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(logger) {
+    wireGatewayApi(db, cfg)
+}
 
 /**
  * Abstracts the config loading
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt
new file mode 100644
index 00000000..f7374204
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt
@@ -0,0 +1,134 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING.  If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.nexus.api
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import io.ktor.util.pipeline.*
+import tech.libeufin.common.*
+import tech.libeufin.nexus.*
+import tech.libeufin.nexus.db.*
+import tech.libeufin.nexus.db.PaymentDAO.*
+import java.time.Instant
+
+
+fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) {
+    get("/taler-wire-gateway/config") {
+        call.respond(WireGatewayConfig(
+            currency = cfg.currency
+        ))
+    }
+    post("/taler-wire-gateway/transfer") {
+        val req = call.receive<TransferRequest>()
+        cfg.checkCurrency(req.amount)
+        // TODO
+        /*val res = db.exchange.transfer(
+            req = req,
+            login = username,
+            now = Instant.now()
+        )
+        when (res) {
+            is TransferResult.UnknownExchange -> throw unknownAccount(username)
+            is TransferResult.NotAnExchange -> throw conflict(
+                "$username is not an exchange account.",
+                TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
+            )
+            is TransferResult.UnknownCreditor -> throw 
unknownCreditorAccount(req.credit_account.canonical)
+            is TransferResult.BothPartyAreExchange -> throw conflict(
+                "Wire transfer attempted with credit and debit party being 
both exchange account",
+                TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
+            )
+            is TransferResult.ReserveUidReuse -> throw conflict(
+                "request_uid used already",
+                TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
+            )
+            is TransferResult.BalanceInsufficient -> throw conflict(
+                "Insufficient balance for exchange",
+                TalerErrorCode.BANK_UNALLOWED_DEBIT
+            )
+            is TransferResult.Success -> call.respond(
+                TransferResponse(
+                    timestamp = res.timestamp,
+                    row_id = res.id
+                )
+            )
+        }*/
+    }
+    /*suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint(
+        reduce: (List<T>, String) -> Any, 
+        dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> 
List<T>
+    ) {
+        val params = HistoryParams.extract(context.request.queryParameters)
+        val bankAccount = call.bankInfo(db, ctx.payto)
+        
+        if (!bankAccount.isTalerExchange)
+            throw conflict(
+                "$username is not an exchange account.",
+                TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
+            )
+
+        val items = db.exchange.dbLambda(params, bankAccount.bankAccountId, 
ctx.payto)
+
+        if (items.isEmpty()) {
+            call.respond(HttpStatusCode.NoContent)
+        } else {
+            call.respond(reduce(items, bankAccount.payto))
+        }
+    }*/
+    /*get("/taler-wire-gateway/history/incoming") {
+        historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory)
+    }
+    get("/taler-wire-gateway/history/outgoing") {
+        historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory)
+    }*/
+    post("/taler-wire-gateway/admin/add-incoming") {
+        val req = call.receive<AddIncomingRequest>()
+        cfg.checkCurrency(req.amount)
+        val timestamp = Instant.now()
+        val bankId = run {
+            val bytes = ByteArray(16)
+            kotlin.random.Random.nextBytes(bytes)
+            Base32Crockford.encode(bytes)
+        }
+        val res = db.payment.registerTalerableIncoming(IncomingPayment(
+            amount = req.amount,
+            debitPaytoUri = req.debit_account.toString(),
+            wireTransferSubject = "Manual incoming ${req.reserve_pub}",
+            executionTime = Instant.now(),
+            bankId = bankId
+        ), req.reserve_pub)
+        when (res) {
+            IncomingRegistrationResult.ReservePubReuse -> throw conflict(
+                "reserve_pub used already",
+                TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
+            )
+            // TODO timestamp when idempotent
+            is IncomingRegistrationResult.Success -> call.respond(
+                AddIncomingResponse(
+                    timestamp = TalerProtocolTimestamp(timestamp),
+                    row_id = res.id
+                )
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
index d316267f..05548b99 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
@@ -96,10 +96,10 @@ class PaymentDAO(private val db: Database) {
     }
 
     /** Incoming payments registration result */
-    data class IncomingRegistrationResult(
-        val id: Long,
-        val new: Boolean
-    )
+    sealed interface IncomingRegistrationResult {
+        data class Success(val id: Long, val new: Boolean): 
IncomingRegistrationResult
+        data object ReservePubReuse: IncomingRegistrationResult
+    }
 
     /** Register an talerable incoming payment */
     suspend fun registerTalerableIncoming(
@@ -107,7 +107,7 @@ class PaymentDAO(private val db: Database) {
         reservePub: EddsaPublicKey
     ): IncomingRegistrationResult = db.conn { conn ->
         val stmt = conn.prepareStatement("""
-            SELECT out_found, out_tx_id
+            SELECT out_reserve_pub_reuse, out_found, out_tx_id
             FROM register_incoming_and_talerable((?,?)::taler_amount,?,?,?,?,?)
         """)
         val executionTime = paymentData.executionTime.micros()
@@ -119,10 +119,13 @@ class PaymentDAO(private val db: Database) {
         stmt.setString(6, paymentData.bankId)
         stmt.setBytes(7, reservePub.raw)
         stmt.one {
-            IncomingRegistrationResult(
-                it.getLong("out_tx_id"),
-                !it.getBoolean("out_found")
-            )
+            when {
+                it.getBoolean("out_reserve_pub_reuse") -> 
IncomingRegistrationResult.ReservePubReuse
+                else -> IncomingRegistrationResult.Success(
+                    it.getLong("out_tx_id"),
+                    !it.getBoolean("out_found")
+                )
+            }
         }
     }
 }
\ No newline at end of file
diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt 
b/nexus/src/test/kotlin/WireGatewayApiTest.kt
similarity index 78%
copy from bank/src/test/kotlin/WireGatewayApiTest.kt
copy to nexus/src/test/kotlin/WireGatewayApiTest.kt
index 82154663..a8d94b2f 100644
--- a/bank/src/test/kotlin/WireGatewayApiTest.kt
+++ b/nexus/src/test/kotlin/WireGatewayApiTest.kt
@@ -17,23 +17,25 @@
  * <http://www.gnu.org/licenses/>
  */
 
+import io.ktor.client.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
 import io.ktor.http.*
+import io.ktor.server.testing.*
 import org.junit.Test
-import tech.libeufin.bank.IncomingHistory
-import tech.libeufin.bank.OutgoingHistory
 import tech.libeufin.common.*
 
 class WireGatewayApiTest {
     // GET /accounts/{USERNAME}/taler-wire-gateway/config
     @Test
-    fun config() = bankSetup { _ ->
-        authRoutine(HttpMethod.Get, 
"/accounts/merchant/taler-wire-gateway/config")
+    fun config() = serverSetup { _ ->
+        //authRoutine(HttpMethod.Get, 
"/accounts/merchant/taler-wire-gateway/config")
 
-        client.getA("/accounts/merchant/taler-wire-gateway/config").assertOk()
+        client.get("/taler-wire-gateway/config").assertOk()
     }
 
     // Testing the POST /transfer call from the TWG API.
-    @Test
+    /*@Test
     fun transfer() = bankSetup { _ -> 
         val valid_req = obj {
             "request_uid" to HashCode.rand()
@@ -121,13 +123,13 @@ class WireGatewayApiTest {
                 "request_uid" to randBase32Crockford(65)
             }
         }.assertBadRequest()
-    }
-    
+    }*/
+    /*
     /**
      * Testing the /history/incoming call from the TWG API.
      */
     @Test
-    fun historyIncoming() = bankSetup { 
+    fun historyIncoming() = serverSetup { 
         // Give Foo reasonable debt allowance:
         setMaxDebt("merchant", "KUDOS:1000")
         authRoutine(HttpMethod.Get, 
"/accounts/merchant/taler-wire-gateway/history/incoming")
@@ -166,7 +168,7 @@ class WireGatewayApiTest {
      * Testing the /history/outgoing call from the TWG API.
      */
     @Test
-    fun historyOutgoing() = bankSetup {
+    fun historyOutgoing() = serverSetup {
         setMaxDebt("exchange", "KUDOS:1000000")
         authRoutine(HttpMethod.Get, 
"/accounts/merchant/taler-wire-gateway/history/outgoing")
         historyRoutine<OutgoingHistory>(
@@ -193,67 +195,45 @@ class WireGatewayApiTest {
                 }
             )
         )
-    }
+    }*/
 
     // Testing the /admin/add-incoming call from the TWG API.
     @Test
-    fun addIncoming() = bankSetup { _ -> 
+    fun addIncoming() = serverSetup { _ -> 
         val valid_req = obj {
-            "amount" to "KUDOS:44"
+            "amount" to "CHF:44"
             "reserve_pub" to EddsaPublicKey.rand()
-            "debit_account" to merchantPayto.canonical
+            "debit_account" to grothoffPayto
         }
 
-        authRoutine(HttpMethod.Post, 
"/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req, 
requireAdmin = true)
-
-        // Checking exchange debt constraint.
-        
client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
-            json(valid_req)
-        }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
+        //authRoutine(HttpMethod.Post, 
"/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req, 
requireAdmin = true)
 
-        // Giving debt allowance and checking the OK case.
-        setMaxDebt("merchant", "KUDOS:1000")
-        
client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+        // Check OK
+        client.post("/taler-wire-gateway/admin/add-incoming") {
             json(valid_req)
         }.assertOk()
 
         // Trigger conflict due to reused reserve_pub
-        
client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+        client.post("/taler-wire-gateway/admin/add-incoming") {
             json(valid_req)
         }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
 
         // Currency mismatch
-        
client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+        client.post("/taler-wire-gateway/admin/add-incoming") {
             json(valid_req) { "amount" to "EUR:33" }
         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
 
-        // Unknown account
-        
client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
-            json(valid_req) { 
-                "reserve_pub" to EddsaPublicKey.rand()
-                "debit_account" to unknownPayto
-            }
-        }.assertConflict(TalerErrorCode.BANK_UNKNOWN_DEBTOR)
-
-        // Same account
-        
client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
-            json(valid_req) { 
-                "reserve_pub" to EddsaPublicKey.rand()
-                "debit_account" to exchangePayto
-            }
-        }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE)
-
         // Bad BASE32 reserve_pub
-        
client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+        client.post("/taler-wire-gateway/admin/add-incoming") {
             json(valid_req) { 
                 "reserve_pub" to "I love chocolate"
             }
         }.assertBadRequest()
         
         // Bad BASE32 len reserve_pub
-        
client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+        client.post("/taler-wire-gateway/admin/add-incoming") {
             json(valid_req) { 
-                "reserve_pub" to randBase32Crockford(31)
+                "reserve_pub" to Base32Crockford.encode(ByteArray(31).rand())
             }
         }.assertBadRequest()
     }
diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt
index 8b7eac4a..5a1396a2 100644
--- a/nexus/src/test/kotlin/helpers.kt
+++ b/nexus/src/test/kotlin/helpers.kt
@@ -20,6 +20,9 @@
 import io.ktor.client.*
 import io.ktor.client.engine.mock.*
 import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.server.testing.*
 import kotlinx.coroutines.runBlocking
 import tech.libeufin.common.TalerAmount
 import tech.libeufin.common.db.dbInit
@@ -53,6 +56,20 @@ fun setup(
     }
 }
 
+fun serverSetup(
+    conf: String = "test.conf",
+    lambda: suspend ApplicationTestBuilder.(Database) -> Unit
+) = setup { db, cfg ->
+    testApplication {
+        application {
+            nexusApi(db, cfg)
+        }
+        lambda(db)
+    }
+}
+
+val grothoffPayto = 
"payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans"
+
 val clientKeys = generateNewKeys()
 
 // Gets an HTTP client whose requests are going to be served by 'handler'.

-- 
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]