[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[libeufin] branch master updated: nexus fetch
From: |
gnunet |
Subject: |
[libeufin] branch master updated: nexus fetch |
Date: |
Thu, 09 Nov 2023 17:12:22 +0100 |
This is an automated email from the git hooks/post-receive script.
ms pushed a commit to branch master
in repository libeufin.
The following commit(s) were added to refs/heads/master by this push:
new b2b49493 nexus fetch
b2b49493 is described below
commit b2b49493967b71b58910274f8d3fe87427891c7a
Author: MS <ms@taler.net>
AuthorDate: Thu Nov 9 17:08:14 2023 +0100
nexus fetch
drafting the main logic to get the last incoming transaction
timestamp from the database -> ask EBICS notifications based
on it -> (optionally) store the plain camt.054 to disk.
---
.../main/kotlin/tech/libeufin/nexus/Database.kt | 19 +++
.../main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 130 +++++++++++++++++++--
.../main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 17 +--
.../kotlin/tech/libeufin/nexus/ebics/Ebics3.kt | 2 +-
.../tech/libeufin/nexus/ebics/EbicsCommon.kt | 112 +++++++++---------
nexus/src/test/kotlin/PostFinance.kt | 5 +-
6 files changed, 210 insertions(+), 75 deletions(-)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
index 13e60cde..78fc2039 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
@@ -307,6 +307,25 @@ class Database(dbConfig: String): java.io.Closeable {
stmt.executeQuery()
}
+ /**
+ * Get the last execution time of an incoming transaction. This
+ * serves as the start date for new requests to the bank.
+ *
+ * @return [Instant] or null if no results were found
+ */
+ suspend fun incomingPaymentLastExecTime(): Instant? = runConn { conn ->
+ val stmt = conn.prepareStatement(
+ "SELECT MAX(execution_time) as latest_execution_time FROM
incoming_transactions"
+ )
+ stmt.executeQuery().use {
+ if (!it.next()) return@runConn null
+ val timestamp =
it.getLong("latest_execution_time").microsToJavaInstant()
+ if (timestamp == null)
+ throw Exception("Could not convert latest_execution_time to
Instant")
+ return@runConn timestamp
+ }
+ }
+
/**
* Creates a new incoming payment record in the database.
*
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
index 5442ec26..86e7ec7b 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -4,12 +4,22 @@ import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.*
+import kotlinx.coroutines.runBlocking
import org.apache.commons.compress.archivers.zip.ZipFile
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel
+import tech.libeufin.nexus.ebics.EbicsSideError
+import tech.libeufin.nexus.ebics.EbicsSideException
+import tech.libeufin.nexus.ebics.createEbics3DownloadInitialization
+import tech.libeufin.nexus.ebics.doEbicsDownload
import tech.libeufin.util.ebics_h005.Ebics3Request
import tech.libeufin.util.getXmlDate
+import tech.libeufin.util.toDbMicros
+import java.nio.file.Path
import java.time.Instant
+import java.time.LocalDate
+import java.time.ZoneId
import kotlin.concurrent.fixedRateTimer
+import kotlin.io.path.createDirectories
import kotlin.system.exitProcess
/**
@@ -194,8 +204,100 @@ fun prepReportRequest(
}
/**
- * Fetches the banking records via EBICS, calling the CAMT
- * parsing logic and finally updating the database accordingly.
+ * Downloads content via EBICS, according to the order params passed
+ * by the caller.
+ *
+ * @param cfg configuration handle.
+ * @param bankKeys bank public keys.
+ * @param clientKeys EBICS subscriber private keys.
+ * @param httpClient handle to the HTTP layer.
+ * @param req contains the instructions for the download, namely
+ * which document is going to be downloaded from the bank.
+ * @return the [ByteArray] payload. On an empty response, the array
+ * length is zero. It returns null, if the bank assigned an
+ * error to the EBICS transaction.
+ */
+suspend fun downloadRecords(
+ cfg: EbicsSetupConfig,
+ bankKeys: BankPublicKeysFile,
+ clientKeys: ClientPrivateKeysFile,
+ httpClient: HttpClient,
+ req: Ebics3Request.OrderDetails.BTOrderParams
+): ByteArray? {
+ val initXml = createEbics3DownloadInitialization(
+ cfg,
+ bankKeys,
+ clientKeys,
+ orderParams = req
+ )
+ try {
+ return doEbicsDownload(
+ httpClient,
+ cfg,
+ clientKeys,
+ bankKeys,
+ initXml,
+ isEbics3 = true,
+ tolerateEmptyResult = true
+ )
+ } catch (e: EbicsSideException) {
+ logger.error(e.message)
+ /**
+ * Failing regardless of the error being at the client or at the
+ * bank side. A client with an unreliable bank is not useful, hence
+ * failing here.
+ */
+ exitProcess(1)
+ }
+}
+
+/**
+ * Extracts the archive entries and logs them to the location
+ * optionally specified in the configuration. It does nothing,
+ * if the configuration lacks the log directory.
+ *
+ * @param cfg config handle.
+ * @param content ZIP bytes from the server.
+ */
+fun maybeLogFile(cfg: EbicsSetupConfig, content: ByteArray) {
+ val maybeLogDir = cfg.config.lookupString(
+ "[neuxs-fetch]",
+ "STATEMENT_LOG_DIRECTORY"
+ ) ?: return
+ try { Path.of(maybeLogDir).createDirectories() }
+ catch (e: Exception) {
+ logger.error("Could not create log directory of path: $maybeLogDir")
+ exitProcess(1)
+ }
+ val now = Instant.now()
+ val asUtcDate = LocalDate.ofInstant(now, ZoneId.of("UTC"))
+ content.unzipForEach { fileName, xmlContent ->
+ val f = Path.of(
+
"${asUtcDate.year}-${asUtcDate.monthValue}-${asUtcDate.dayOfMonth}",
+ "${now.toDbMicros()}_$fileName"
+ ).toFile()
+ val completePath = Path.of(maybeLogDir, f.path)
+ // Rare: cannot download the same file twice in the same microsecond.
+ if (f.exists()) {
+ logger.error("Log file exists already at: $completePath")
+ exitProcess(1)
+ }
+ completePath.toFile().writeText(xmlContent)
+ }
+}
+
+/**
+ * Fetches the banking records via EBICS notifications requests.
+ *
+ * It first checks the last execution_time (db column) among the
+ * incoming transactions. If that's not found, it asks the bank
+ * about 'unseen notifications' (= does not specify any date range
+ * in the request). If that's found, it crafts a notification
+ * request with such execution_time as the start date and now as
+ * the end date.
+ *
+ * What this function does NOT do (now): linking documents between
+ * different camt.05x formats and/or pain.002 acknowledgements.
*
* @param cfg config handle.
* @param db database connection
@@ -203,14 +305,28 @@ fun prepReportRequest(
* @param clientKeys EBICS subscriber private keys.
* @param bankKeys bank public keys.
*/
-fun fetchHistory(
+suspend fun fetchHistory(
cfg: EbicsSetupConfig,
db: Database,
httpClient: HttpClient,
clientKeys: ClientPrivateKeysFile,
bankKeys: BankPublicKeysFile
) {
- throw NotImplementedError()
+ // maybe get last execution_date.
+ val lastExecutionTime = db.incomingPaymentLastExecTime()
+ // Asking unseen records.
+ val req = if (lastExecutionTime == null)
prepNotificationRequest(isAppendix = false)
+ else prepNotificationRequest(lastExecutionTime, isAppendix = false)
+ val maybeContent = downloadRecords(
+ cfg,
+ bankKeys,
+ clientKeys,
+ httpClient,
+ req
+ ) ?: exitProcess(1) // client is wrong, failing.
+
+ if (maybeContent.isEmpty()) return
+ maybeLogFile(cfg, maybeContent)
}
class EbicsFetch: CliktCommand("Fetches bank records") {
@@ -252,7 +368,7 @@ class EbicsFetch: CliktCommand("Fetches bank records") {
val httpClient = HttpClient()
if (transient) {
logger.info("Transient mode: fetching once and returning.")
- fetchHistory(cfg, db, httpClient, clientKeys, bankKeys)
+ runBlocking { fetchHistory(cfg, db, httpClient, clientKeys,
bankKeys) }
return
}
val frequency: NexusFrequency = doOrFail {
@@ -263,14 +379,14 @@ class EbicsFetch: CliktCommand("Fetches bank records") {
logger.debug("Running with a frequency of ${frequency.fromConfig}")
if (frequency.inSeconds == 0) {
logger.warn("Long-polling not implemented, running therefore in
transient mode")
- fetchHistory(cfg, db, httpClient, clientKeys, bankKeys)
+ runBlocking { fetchHistory(cfg, db, httpClient, clientKeys,
bankKeys) }
return
}
fixedRateTimer(
name = "ebics submit period",
period = (frequency.inSeconds * 1000).toLong(),
action = {
- fetchHistory(cfg, db, httpClient, clientKeys, bankKeys)
+ runBlocking { fetchHistory(cfg, db, httpClient, clientKeys,
bankKeys) }
}
)
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
index ba713ae2..8705c500 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
@@ -24,24 +24,19 @@ import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.*
import kotlinx.coroutines.runBlocking
-import tech.libeufin.nexus.ebics.EbicsEarlyErrorCode
-import tech.libeufin.nexus.ebics.EbicsEarlyException
+import tech.libeufin.nexus.ebics.EbicsSideError
+import tech.libeufin.nexus.ebics.EbicsSideException
import tech.libeufin.nexus.ebics.EbicsUploadException
import tech.libeufin.nexus.ebics.submitPain001
import tech.libeufin.util.parsePayto
import tech.libeufin.util.toDbMicros
-import java.io.File
import java.nio.file.Path
-import java.text.DateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.util.*
-import javax.xml.crypto.Data
import kotlin.concurrent.fixedRateTimer
import kotlin.io.path.createDirectories
-import kotlin.io.path.createParentDirectories
-import kotlin.math.log
import kotlin.system.exitProcess
/**
@@ -112,12 +107,12 @@ private suspend fun submitInitiatedPayment(
bankPublicKeysFile,
httpClient
)
- } catch (early: EbicsEarlyException) {
- val errorStage = when (early.earlyEc) {
- EbicsEarlyErrorCode.HTTP_POST_FAILED ->
+ } catch (early: EbicsSideException) {
+ val errorStage = when (early.sideEc) {
+ EbicsSideError.HTTP_POST_FAILED ->
NexusSubmissionStage.http // transient error
/**
- * Any other [EbicsEarlyErrorCode] should be treated as permanent,
+ * Any other [EbicsSideError] should be treated as permanent,
* as they involve invalid signatures or an unexpected response
* format. For this reason, they get the "ebics" stage assigned
* below, that will cause the payment as permanently failed and
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
index a0c75aa0..995483a3 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
@@ -193,7 +193,7 @@ fun createEbics3RequestForUploadTransferPhase(
/**
* Collects all the steps to prepare the submission of a pain.001
* document to the bank, and finally send it. Indirectly throws
- * [EbicsEarlyException] or [EbicsUploadException]. The first means
+ * [EbicsSideException] or [EbicsUploadException]. The first means
* that the bank sent an invalid response or signature, the second
* that a proper EBICS or business error took place. The caller must
* catch those exceptions and decide the retry policy.
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
index fd72b87a..0d788e62 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
@@ -214,7 +214,7 @@ fun generateKeysPdf(
* @param tolerateBankReturnCode Business return code that may be accepted
instead of
* EBICS_OK. Typically,
EBICS_NO_DOWNLOAD_DATA_AVAILABLE is tolerated
* when asking for new incoming payments.
- * @return [EbicsResponseContent] or throws [EbicsEarlyException]
+ * @return [EbicsResponseContent] or throws [EbicsSideException]
*/
suspend fun postEbics(
client: HttpClient,
@@ -224,9 +224,9 @@ suspend fun postEbics(
isEbics3: Boolean
): EbicsResponseContent {
val respXml = client.postToBank(cfg.hostBaseUrl, xmlReq)
- ?: throw EbicsEarlyException(
+ ?: throw EbicsSideException(
"POSTing to ${cfg.hostBaseUrl} failed",
- earlyEc = EbicsEarlyErrorCode.HTTP_POST_FAILED
+ sideEc = EbicsSideError.HTTP_POST_FAILED
)
return parseAndValidateEbicsResponse(
bankKeys,
@@ -257,11 +257,12 @@ private fun areCodesOk(ebicsResponseContent:
EbicsResponseContent) =
* @param isEbics3 true for EBICS 3, false otherwise.
* @param tolerateEmptyResult true if the EC EBICS_NO_DOWNLOAD_DATA_AVAILABLE
* should be tolerated as the bank-technical error, false otherwise.
- * @return the bank response as an uncompressed [ByteArray], or null if one
error took place.
- * If the request tolerates an empty download content, then the empty
- * array is returned. If the request does not tolerate an empty
response
- * any non-EBICS_OK error as the EBICS- or bank-technical EC
constitutes
- * an error.
+ * @return the bank response as an uncompressed [ByteArray], or null if one
+ * error took place. Definition of error: any EBICS- or bank-technical
+ * EC pairs where at least one is not EBICS_OK, or if
tolerateEmptyResult
+ * is true, the bank-technical EC EBICS_NO_DOWNLOAD_DATA_AVAILABLE is
allowed
+ * other than EBICS_OK. If the request tolerates an empty download
content,
+ * then the empty array is returned. The function may throw
[EbicsAdditionalErrors].
*/
suspend fun doEbicsDownload(
client: HttpClient,
@@ -287,11 +288,11 @@ suspend fun doEbicsDownload(
return null
}
val tId = initResp.transactionID
- if (tId == null) {
- tech.libeufin.nexus.logger.error("Transaction ID not found in the init
response, cannot do transfer phase, failing.")
- return null
- }
- logger.debug("EBICS download transaction got ID: $tId")
+ ?: throw EbicsSideException(
+ "EBICS download init phase did not return a transaction ID, cannot
do the transfer phase.",
+ sideEc = EbicsSideError.EBICS_UPLOAD_TRANSACTION_ID_MISSING
+ )
+ logger.debug("EBICS download transaction passed the init phase, got ID:
$tId")
val howManySegments = initResp.numSegments
if (howManySegments == null) {
tech.libeufin.nexus.logger.error("Init response lacks the quantity of
segments, failing.")
@@ -300,13 +301,15 @@ suspend fun doEbicsDownload(
val ebicsChunks = mutableListOf<String>()
// Getting the chunk(s)
val firstDataChunk = initResp.orderDataEncChunk
- if (firstDataChunk == null) {
- tech.libeufin.nexus.logger.error("Could not get the first data chunk,
although the EBICS_OK return code, failing.")
- return null
- }
+ ?: throw EbicsSideException(
+ "OrderData element not found, despite non empty payload, failing.",
+ sideEc = EbicsSideError.ORDER_DATA_ELEMENT_NOT_FOUND
+ )
val dataEncryptionInfo = initResp.dataEncryptionInfo ?: run {
- tech.libeufin.nexus.logger.error("EncryptionInfo element not found,
despite non empty payload, failing.")
- return null
+ throw EbicsSideException(
+ "EncryptionInfo element not found, despite non empty payload,
failing.",
+ sideEc = EbicsSideError.ENCRYPTION_INFO_ELEMENT_NOT_FOUND
+ )
}
ebicsChunks.add(firstDataChunk)
// proceed with the transfer phase.
@@ -317,9 +320,11 @@ suspend fun doEbicsDownload(
else createEbics25DownloadTransferPhase(cfg, clientKeys, x,
howManySegments, tId)
val transResp = postEbics(client, cfg, bankKeys, transReq, isEbics3)
- if (!areCodesOk(transResp)) { // FIXME: consider tolerating
EBICS_NO_DOWNLOAD_DATA_AVAILABLE.
- tech.libeufin.nexus.logger.error("EBICS transfer segment #$x
failed.")
- return null
+ if (!areCodesOk(transResp)) {
+ throw EbicsSideException(
+ "EBICS transfer segment #$x failed.",
+ sideEc = EbicsSideError.TRANSFER_SEGMENT_FAILED
+ )
}
val chunk = transResp.orderDataEncChunk
if (chunk == null) {
@@ -334,38 +339,35 @@ suspend fun doEbicsDownload(
dataEncryptionInfo,
ebicsChunks
)
- // payload reconstructed, ack to the bank.
- val ackXml = if (isEbics3)
+ // payload reconstructed, receipt to the bank.
+ val receiptXml = if (isEbics3)
createEbics3DownloadReceiptPhase(cfg, clientKeys, tId)
else createEbics25DownloadReceiptPhase(cfg, clientKeys, tId)
- try {
- postEbics(
- client,
- cfg,
- bankKeys,
- ackXml,
- isEbics3
- )
- } catch (e: EbicsEarlyException) {
- logger.error("Download receipt phase failed: " + e.message)
- return null
- }
- // receipt phase OK, can now return the payload as an XML string.
- return try {
- payloadBytes
- } catch (e: Exception) {
- logger.error("Could not get the XML string out of payload bytes.")
- null
- }
+ // Sending the receipt to the bank.
+ postEbics(
+ client,
+ cfg,
+ bankKeys,
+ receiptXml,
+ isEbics3
+ )
+ // Receipt didn't throw, can now return the payload.
+ return payloadBytes
}
-enum class EbicsEarlyErrorCode {
+/**
+ * These errors affect an EBICS transaction regardless
+ * of the standard error codes.
+ */
+enum class EbicsSideError {
BANK_SIGNATURE_DIDNT_VERIFY,
BANK_RESPONSE_IS_INVALID,
+ ENCRYPTION_INFO_ELEMENT_NOT_FOUND,
+ ORDER_DATA_ELEMENT_NOT_FOUND,
+ TRANSFER_SEGMENT_FAILED,
/**
- * That's the bank fault, as this value should be there even
- * if there was an error.
+ * This might indicate that the EBICS transaction had errors.
*/
EBICS_UPLOAD_TRANSACTION_ID_MISSING,
/**
@@ -381,9 +383,9 @@ enum class EbicsEarlyErrorCode {
* and successfully verify its signature. They bring therefore NO
* business meaning and may be retried.
*/
-class EbicsEarlyException(
+class EbicsSideException(
msg: String,
- val earlyEc: EbicsEarlyErrorCode
+ val sideEc: EbicsSideError
) : Exception(msg)
/**
@@ -393,7 +395,7 @@ class EbicsEarlyException(
* @param bankKeys provides the bank auth pub, to verify the signature.
* @param responseStr raw XML response from the bank
* @param withEbics3 true if the communication is EBICS 3, false otherwise.
- * @return [EbicsResponseContent] or throw [EbicsEarlyException]
+ * @return [EbicsResponseContent] or throw [EbicsSideException]
*/
fun parseAndValidateEbicsResponse(
bankKeys: BankPublicKeysFile,
@@ -403,9 +405,9 @@ fun parseAndValidateEbicsResponse(
val responseDocument = try {
XMLUtil.parseStringIntoDom(responseStr)
} catch (e: Exception) {
- throw EbicsEarlyException(
+ throw EbicsSideException(
"Bank response apparently invalid",
- earlyEc = EbicsEarlyErrorCode.BANK_RESPONSE_IS_INVALID
+ sideEc = EbicsSideError.BANK_RESPONSE_IS_INVALID
)
}
if (!XMLUtil.verifyEbicsDocument(
@@ -413,9 +415,9 @@ fun parseAndValidateEbicsResponse(
bankKeys.bank_authentication_public_key,
withEbics3
)) {
- throw EbicsEarlyException(
+ throw EbicsSideException(
"Bank signature did not verify",
- earlyEc = EbicsEarlyErrorCode.BANK_SIGNATURE_DIDNT_VERIFY
+ sideEc = EbicsSideError.BANK_SIGNATURE_DIDNT_VERIFY
)
}
if (withEbics3)
@@ -555,9 +557,9 @@ suspend fun doEbicsUpload(
)
// Init phase OK, proceeding with the transfer phase.
val tId = initResp.transactionID
- ?: throw EbicsEarlyException(
+ ?: throw EbicsSideException(
"EBICS upload init phase did not return a transaction ID, cannot
do the transfer phase.",
- earlyEc = EbicsEarlyErrorCode.EBICS_UPLOAD_TRANSACTION_ID_MISSING
+ sideEc = EbicsSideError.EBICS_UPLOAD_TRANSACTION_ID_MISSING
)
val transferXml = createEbics3RequestForUploadTransferPhase(
cfg,
diff --git a/nexus/src/test/kotlin/PostFinance.kt
b/nexus/src/test/kotlin/PostFinance.kt
index aa79d111..80c76362 100644
--- a/nexus/src/test/kotlin/PostFinance.kt
+++ b/nexus/src/test/kotlin/PostFinance.kt
@@ -1,5 +1,6 @@
import io.ktor.client.*
import kotlinx.coroutines.runBlocking
+import org.junit.Ignore
import org.junit.Test
import tech.libeufin.nexus.*
import tech.libeufin.nexus.ebics.*
@@ -21,6 +22,7 @@ private fun prep(): EbicsSetupConfig {
return EbicsSetupConfig(handle)
}
+@Ignore
class Iso20022 {
private val yesterday: Instant = Instant.now().minus(1, ChronoUnit.DAYS)
@@ -40,7 +42,7 @@ class Iso20022 {
*/
@Test
fun getStatement() {
- val inflatedBytes = download(prepStatementRequest(yesterday))
+ val inflatedBytes = download(prepStatementRequest())
inflatedBytes?.unzipForEach { name, content ->
println(name)
println(content)
@@ -153,6 +155,7 @@ class Iso20022 {
}
}
+@Ignore
class PostFinance {
// Tests sending client keys to the PostFinance test platform.
@Test
--
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.
- [libeufin] branch master updated: nexus fetch,
gnunet <=
- [libeufin] branch master updated: nexus fetch, gnunet, 2023/11/09
- [libeufin] branch master updated: nexus fetch, gnunet, 2023/11/10
- [libeufin] branch master updated: nexus fetch, gnunet, 2023/11/11
- [libeufin] branch master updated: nexus fetch, gnunet, 2023/11/15
- [libeufin] branch master updated: nexus fetch, gnunet, 2023/11/15
- [libeufin] branch master updated: nexus fetch, gnunet, 2023/11/20
- [libeufin] branch master updated: nexus fetch, gnunet, 2023/11/21