commit 92c06e5c679fd878a4960e4d92f7aaa981f68653 parent d20e333bb6c423a268f89f91633bfc5187a3f463 Author: Antoine A <> Date: Sat, 28 Feb 2026 15:00:17 +0100 common: add wg transfer metadata Diffstat:
22 files changed, 302 insertions(+), 75 deletions(-)
diff --git a/database-versioning/libeufin-bank-0015.sql b/database-versioning/libeufin-bank-0015.sql @@ -26,4 +26,8 @@ ALTER TABLE taler_withdrawal_operations ALTER COLUMN wallet_bank_account DROP NO ALTER TABLE taler_withdrawal_operations ADD COLUMN exchange_bank_account INT8 REFERENCES bank_accounts(bank_account_id) ON DELETE SET NULL; UPDATE taler_withdrawal_operations SET exchange_bank_account=(SELECT bank_account_id FROM bank_accounts WHERE internal_payto=selected_exchange_payto); ALTER TABLE taler_withdrawal_operations DROP COLUMN selected_exchange_payto; + +-- Add outgoing transactions metadata field +ALTER TABLE transfer_operations ADD COLUMN metadata TEXT; + COMMIT; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -666,6 +666,7 @@ CREATE FUNCTION taler_transfer( IN in_subject TEXT, IN in_amount taler_amount, IN in_exchange_base_url TEXT, + IN in_metadata TEXT, IN in_credit_account_payto TEXT, IN in_username TEXT, IN in_timestamp INT8, @@ -699,6 +700,7 @@ BEGIN SELECT (amount != in_amount OR creditor_payto != in_credit_account_payto OR exchange_base_url != in_exchange_base_url + OR metadata != in_metadata OR wtid != in_wtid) ,transfer_operation_id, transfer_date INTO out_request_uid_reuse, out_tx_row_id, out_timestamp @@ -738,6 +740,7 @@ IF NOT FOUND THEN wtid, amount, exchange_base_url, + metadata, transfer_date, exchange_outgoing_id, creditor_payto, @@ -749,6 +752,7 @@ IF NOT FOUND THEN in_wtid, in_amount, in_exchange_base_url, + in_metadata, in_timestamp, NULL, in_credit_account_payto, @@ -762,7 +766,7 @@ ELSIF out_both_exchanges THEN END IF; IF creditor_admin THEN - -- Check conversion is enabled + -- Check if this is a conversion bounce IF NOT in_conversion THEN out_creditor_admin=TRUE; RETURN; @@ -781,6 +785,7 @@ IF creditor_admin THEN wtid, amount, exchange_base_url, + metadata, transfer_date, exchange_outgoing_id, creditor_payto, @@ -792,6 +797,7 @@ IF creditor_admin THEN in_wtid, in_amount, in_exchange_base_url, + in_metadata, in_timestamp, NULL, in_credit_account_payto, @@ -815,13 +821,6 @@ IF creditor_admin THEN ,'exchange bounced' ); END IF; - --- Check if this is a conversion bounce -out_creditor_admin=creditor_admin AND NOT in_conversion; -IF out_creditor_admin THEN - RETURN; -END IF; - -- Perform bank transfer SELECT out_balance_insufficient, @@ -856,6 +855,7 @@ INSERT INTO transfer_operations ( wtid, amount, exchange_base_url, + metadata, transfer_date, exchange_outgoing_id, creditor_payto, @@ -867,6 +867,7 @@ INSERT INTO transfer_operations ( in_wtid, in_amount, in_exchange_base_url, + in_metadata, in_timestamp, outgoing_id, in_credit_account_payto, diff --git a/database-versioning/libeufin-nexus-0014.sql b/database-versioning/libeufin-nexus-0014.sql @@ -0,0 +1,25 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2026 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER 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 General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +BEGIN; + +SELECT _v.register_patch('libeufin-nexus-0014', NULL, NULL); + +SET search_path TO libeufin_nexus; + +-- Add outgoing transactions metadata field +ALTER TABLE transfer_operations ADD COLUMN metadata TEXT; +ALTER TABLE talerable_outgoing_transactions ADD COLUMN metadata TEXT; +COMMIT; diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -1,6 +1,6 @@ -- -- This file is part of TALER --- Copyright (C) 2023-2025 Taler Systems SA +-- Copyright (C) 2023, 2024, 2025, 2026 Taler Systems SA -- -- TALER is free software; you can redistribute it and/or modify it under the -- terms of the GNU General Public License as published by the Free Software @@ -83,6 +83,7 @@ CREATE FUNCTION register_outgoing( ,IN in_acct_svcr_ref TEXT ,IN in_wtid BYTEA ,IN in_exchange_url TEXT + ,IN in_metadata TEXT ,OUT out_tx_id INT8 ,OUT out_found BOOLEAN ,OUT out_initiated BOOLEAN @@ -95,13 +96,14 @@ local_subject TEXT; local_credit_payto TEXT; local_wtid BYTEA; local_exchange_base_url TEXT; +local_metadata TEXT; local_end_to_end_id TEXT; BEGIN -- Check if already registered SELECT outgoing_transaction_id, subject, credit_payto, (amount).val, (amount).frac, - wtid, exchange_base_url + wtid, exchange_base_url, metadata INTO out_tx_id, local_subject, local_credit_payto, local_amount.val, local_amount.frac, - local_wtid, local_exchange_base_url + local_wtid, local_exchange_base_url, local_metadata FROM outgoing_transactions LEFT JOIN talerable_outgoing_transactions USING (outgoing_transaction_id) WHERE end_to_end_id = in_end_to_end_id OR acct_svcr_ref = in_acct_svcr_ref; out_found=FOUND; @@ -123,13 +125,16 @@ IF out_found THEN IF local_exchange_base_url IS DISTINCT FROM in_exchange_url THEN RAISE NOTICE 'outgoing tx %: stored exchange base url is % got %', in_end_to_end_id, local_exchange_base_url, in_exchange_url; END IF; + IF local_metadata IS DISTINCT FROM in_metadata THEN + RAISE NOTICE 'outgoing tx %: stored metadata is % got %', in_end_to_end_id, local_metadata, in_metadata; + END IF; END IF; -- Check if initiated SELECT initiated_outgoing_transaction_id, subject, credit_payto, (amount).val, (amount).frac, - wtid, exchange_base_url + wtid, exchange_base_url, metadata INTO init_id, local_subject, local_credit_payto, local_amount.val, local_amount.frac, - local_wtid, local_exchange_base_url + local_wtid, local_exchange_base_url, local_metadata FROM initiated_outgoing_transactions LEFT JOIN transfer_operations USING (initiated_outgoing_transaction_id) WHERE end_to_end_id = in_end_to_end_id; out_initiated=FOUND; @@ -151,6 +156,9 @@ IF out_initiated AND NOT out_found THEN IF in_exchange_url IS NOT NULL AND local_exchange_base_url != in_exchange_url THEN RAISE NOTICE 'outgoing tx %: initiated exchange base url is % got %', in_end_to_end_id, local_exchange_base_url, in_exchange_url; END IF; + IF in_metadata IS NOT NULL AND local_metadata != in_metadata THEN + RAISE NOTICE 'outgoing tx %: initiated metadata is % got %', in_end_to_end_id, local_metadata, in_metadata; + END IF; END IF; IF NOT out_found THEN @@ -175,8 +183,8 @@ IF NOT out_found THEN RETURNING outgoing_transaction_id INTO out_tx_id; - -- Register as talerable if contains wtid and exchange URL - IF in_wtid IS NOT NULL OR in_exchange_url IS NOT NULL THEN + -- Register as talerable if contains wtid + IF in_wtid IS NOT NULL THEN SELECT end_to_end_id INTO local_end_to_end_id FROM talerable_outgoing_transactions JOIN outgoing_transactions USING (outgoing_transaction_id) @@ -186,8 +194,17 @@ IF NOT out_found THEN RAISE NOTICE 'wtid reuse: tx % and tx % have the same wtid %', in_end_to_end_id, local_end_to_end_id, in_wtid; END IF; ELSE - INSERT INTO talerable_outgoing_transactions(outgoing_transaction_id, wtid, exchange_base_url) - VALUES (out_tx_id, in_wtid, in_exchange_url); + INSERT INTO talerable_outgoing_transactions( + outgoing_transaction_id, + wtid, + exchange_base_url, + metadata + ) VALUES ( + out_tx_id, + in_wtid, + in_exchange_url, + in_metadata + ); PERFORM pg_notify('nexus_outgoing_tx', out_tx_id::text); END IF; END IF; @@ -416,6 +433,7 @@ CREATE FUNCTION taler_transfer( IN in_subject TEXT, IN in_amount taler_amount, IN in_exchange_base_url TEXT, + IN in_metadata TEXT, IN in_credit_account_payto TEXT, IN in_end_to_end_id TEXT, IN in_timestamp INT8, @@ -432,6 +450,7 @@ BEGIN SELECT (amount != in_amount OR credit_payto != in_credit_account_payto OR exchange_base_url != in_exchange_base_url + OR exchange_base_url != in_exchange_base_url OR wtid != in_wtid) ,transfer_operations.initiated_outgoing_transaction_id, initiation_time INTO out_request_uid_reuse, out_tx_row_id, out_timestamp @@ -467,11 +486,13 @@ INSERT INTO transfer_operations( ,request_uid ,wtid ,exchange_base_url + ,metadata ) VALUES ( out_tx_row_id ,in_request_uid ,in_wtid ,in_exchange_base_url + ,in_metadata ); out_timestamp = in_timestamp; PERFORM pg_notify('nexus_outgoing_tx', out_tx_row_id::text); diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -224,7 +224,7 @@ data class RegisterAccountRequest( } companion object { - private val USERNAME_REGEX = Regex("^[a-zA-Z0-9\\-\\._~]{1,126}$") + private val USERNAME_REGEX = Regex("^[a-zA-Z0-9-._~]{1,126}$") } } diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -80,6 +80,7 @@ class ExchangeDAO(private val db: Database) { ,txs.creditor_name ,wtid ,exchange_base_url + ,transfer_operations.metadata FROM taler_exchange_outgoing AS tfr JOIN transfer_operations USING (exchange_outgoing_id) JOIN bank_account_transactions AS txs @@ -93,6 +94,7 @@ class ExchangeDAO(private val db: Database) { credit_account = it.getBankPayto("creditor_payto", "creditor_name", db.ctx), wtid = ShortHashCode(it.getBytes("wtid")), exchange_base_url = it.getString("exchange_base_url"), + metadata = it.getString("metadata"), debit_fee = null ) } @@ -132,7 +134,7 @@ class ExchangeDAO(private val db: Database) { taler_transfer ( ?, ?, ?, (?,?)::taler_amount, - ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ? ); """ ) { @@ -143,6 +145,7 @@ class ExchangeDAO(private val db: Database) { bind(subject) bind(req.amount) bind(req.exchange_base_url.url.toString()) + bind(req.metadata) bind(req.credit_account.canonical) bind(username) bind(timestamp) @@ -173,6 +176,7 @@ class ExchangeDAO(private val db: Database) { SELECT wtid ,exchange_base_url + ,metadata ,transfer_date ,(amount).val AS amount_val ,(amount).frac AS amount_frac @@ -191,6 +195,7 @@ class ExchangeDAO(private val db: Database) { status_msg = it.getString("status_msg"), amount = it.getAmount("amount", db.bankCurrency), origin_exchange_url = it.getString("exchange_base_url"), + metadata = it.getString("metadata"), wtid = ShortHashCode(it.getBytes("wtid")), credit_account = it.getBankPayto("creditor_payto", null, db.ctx), timestamp = it.getTalerTimestamp("transfer_date"), diff --git a/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt b/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt @@ -1450,8 +1450,8 @@ class CoreBankTransactionsApiTest { tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction tx("merchant", "KUDOS:1", "exchange", "ADMIN BALANCE ADJUST") // Bounce admin balance adjust val reservePub = EddsaPublicKey.randEdsaKey() - tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Accept incoming - tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Bounce reserve_pub reuse + tx("merchant", "KUDOS:1", "exchange", fmtIncomingSubject(reservePub)) // Accept incoming + tx("merchant", "KUDOS:1", "exchange", fmtIncomingSubject(reservePub)) // Bounce reserve_pub reuse assertBalance("merchant", "-KUDOS:1") assertBalance("exchange", "+KUDOS:1") @@ -1462,8 +1462,8 @@ class CoreBankTransactionsApiTest { tx("exchange", "KUDOS:1", "merchant", "Malformed") // Warn malformed transaction val wtid = ShortHashCode.rand() val exchange = BaseURL.parse("http://exchange.example.com/") - tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Accept outgoing - tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Warn wtid reuse + tx("exchange", "KUDOS:1", "merchant", fmtOutgoingSubject(wtid, exchange)) // Accept outgoing + tx("exchange", "KUDOS:1", "merchant", fmtOutgoingSubject(wtid, exchange)) // Warn wtid reuse assertBalance("merchant", "+KUDOS:3") assertBalance("exchange", "-KUDOS:3") diff --git a/libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -60,6 +60,27 @@ class WireGatewayApiTest { client.postA("/accounts/exchange/taler-wire-gateway/transfer") { json(valid_req) }.assertOk() + + val with_metadata = obj(valid_req) { + "request_uid" to HashCode.rand() + "metadata" to "ID" + "wtid" to ShortHashCode.rand() + } + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(with_metadata) + }.assertOk() + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(with_metadata) + }.assertOk() + + // Malformed metadata + listOf("bad_id", "bad id", "bad@id.com", "A".repeat(41)).forEach { + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(valid_req) { + "metadata" to it + } + }.assertBadRequest() + } val new_req = obj(valid_req) { "request_uid" to HashCode.rand() @@ -197,9 +218,24 @@ class WireGatewayApiTest { assertEquals(TransferStatusState.success, tx.status) assertEquals(TalerAmount("KUDOS:0.12"), tx.amount) assertEquals("http://exchange.example.com/", tx.origin_exchange_url) + assertNull(tx.metadata) assertEquals(wtid, tx.wtid) assertEquals(resp.timestamp, tx.timestamp) } + + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(valid_req) { + "request_uid" to HashCode.rand() + "metadata" to "ID" + "wtid" to ShortHashCode.rand() + } + }.assertOkJson<TransferResponse> { + client.getA("/accounts/exchange/taler-wire-gateway/transfers/${it.row_id}") + .assertOkJson<TransferStatus> { tx -> + assertEquals(tx.metadata, "ID") + } + } + // Unknown account wtid = ShortHashCode.rand() client.postA("/accounts/exchange/taler-wire-gateway/transfer") { @@ -320,7 +356,10 @@ class WireGatewayApiTest { ids = { it.outgoing_transactions.map { it.row_id } }, registered = listOf( // Transactions using clean add incoming logic - { transfer("KUDOS:10") } + { transfer("KUDOS:10") }, + + // And with metadata + { transfer("KUDOS:12", metadata = "CON:ID") } ), ignored = listOf( // Failed transfer @@ -336,6 +375,16 @@ class WireGatewayApiTest { { tx("exchange", "KUDOS:10", "merchant", "ignored") }, ) ) + assertContentEquals( + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?limit=2") + .assertOkJson<OutgoingHistory>() + .outgoing_transactions + .map { it.amount.toString() to it.metadata } + ,listOf( + "KUDOS:10" to null, + "KUDOS:12" to "CON:ID", + ) + ) } suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) { diff --git a/libeufin-bank/src/test/kotlin/helpers.kt b/libeufin-bank/src/test/kotlin/helpers.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -17,7 +17,6 @@ * <http://www.gnu.org/licenses/> */ -import io.ktor.client.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* @@ -204,7 +203,7 @@ suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, } /** Perform a taler outgoing transaction of [amount] from exchange to merchant */ -suspend fun ApplicationTestBuilder.transfer(amount: String, payto: IbanPayto = merchantPayto) { +suspend fun ApplicationTestBuilder.transfer(amount: String, payto: IbanPayto = merchantPayto, metadata: String? = null) { client.postA("/accounts/exchange/taler-wire-gateway/transfer") { json { "request_uid" to HashCode.rand() @@ -212,6 +211,7 @@ suspend fun ApplicationTestBuilder.transfer(amount: String, payto: IbanPayto = m "exchange_base_url" to "http://exchange.example.com/" "wtid" to ShortHashCode.rand() "credit_account" to payto + "metadata" to metadata } }.assertOk() } @@ -405,5 +405,4 @@ fun assertException(msg: String, lambda: () -> Unit) { /* ----- Random data generation ----- */ fun randBase32Crockford(length: Int) = Base32Crockford.encode(ByteArray(length).rand()) -fun randIncomingSubject(reservePub: EddsaPublicKey): String = "$reservePub" -fun randOutgoingSubject(wtid: ShortHashCode, url: BaseURL): String = "$wtid $url" -\ No newline at end of file +fun fmtIncomingSubject(reservePub: EddsaPublicKey): String = "$reservePub" +\ No newline at end of file diff --git a/libeufin-common/src/main/kotlin/Constants.kt b/libeufin-common/src/main/kotlin/Constants.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025 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 @@ -26,7 +26,7 @@ const val SERIALIZATION_RETRY: Int = 30 const val MAX_BODY_LENGTH: Int = 4 * 1024 // 4kB // API version -const val WIRE_GATEWAY_API_VERSION: String = "4:0:3" +const val WIRE_GATEWAY_API_VERSION: String = "5:0:0" const val REVENUE_API_VERSION: String = "1:1:1" const val OBSERVABILITY_API_VERSION: String = "0:0:0" diff --git a/libeufin-common/src/main/kotlin/Subject.kt b/libeufin-common/src/main/kotlin/Subject.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -178,8 +178,27 @@ fun parseIncomingSubject(subject: String?): IncomingSubject { return best?.subject ?: throw Exception("missing reserve public key") } +/** Format an outgoing subject */ +fun fmtOutgoingSubject(wtid: ShortHashCode, url: BaseURL, metadata: String? = null): String = buildString { + if (metadata != null) { + append(metadata) + append(" ") + } + append(wtid) + append(" ") + append(url) +} + /** Extract the reserve public key from an incoming Taler transaction subject */ -fun parseOutgoingSubject(subject: String): Pair<ShortHashCode, BaseURL> { - val (wtid, baseUrl) = subject.splitOnce(" ") ?: throw Exception("malformed outgoing subject") - return Pair(EddsaPublicKey(wtid), BaseURL.parse(baseUrl)) +fun parseOutgoingSubject(subject: String): Triple<ShortHashCode, BaseURL, String?> { + var iterator = subject.splitToSequence(' ').iterator(); + val first = iterator.next() + if (!iterator.hasNext()) throw Exception("malformed outgoing subject") + val second = iterator.next() + if (iterator.hasNext()) { + val third = iterator.next() + return Triple(EddsaPublicKey(second), BaseURL.parse(third), first) + } else { + return Triple(EddsaPublicKey(first), BaseURL.parse(second), null) + } } \ No newline at end of file diff --git a/libeufin-common/src/main/kotlin/TalerMessage.kt b/libeufin-common/src/main/kotlin/TalerMessage.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -19,7 +19,8 @@ package tech.libeufin.common -import kotlinx.serialization.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable enum class IncomingType { reserve, @@ -51,8 +52,18 @@ data class TransferRequest( val amount: TalerAmount, val exchange_base_url: BaseURL, val wtid: ShortHashCode, - val credit_account: Payto -) + val credit_account: Payto, + val metadata: String? = null, +) { + init { + if (metadata != null && !METADATA_REGEX.matches(metadata)) + throw badRequest("metadata '$metadata' is malformed, must match [a-zA-Z0-9-.+:]{1,40}") + } + + companion object { + private val METADATA_REGEX = Regex("^[a-zA-Z0-9-.:]{1,40}$") + } +} /** Response POST /taler-wire-gateway/transfer */ @Serializable @@ -83,6 +94,7 @@ data class TransferStatus( val status_msg: String? = null, val amount: TalerAmount, val origin_exchange_url: String, + val metadata: String? = null, val wtid: ShortHashCode, val credit_account: String, val timestamp: TalerTimestamp @@ -176,6 +188,7 @@ data class OutgoingTransaction( val credit_account: String, val wtid: ShortHashCode, val exchange_base_url: String, + val metadata: String? = null, val debit_fee: TalerAmount? = null ) diff --git a/libeufin-common/src/test/kotlin/SubjectTest.kt b/libeufin-common/src/test/kotlin/SubjectTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -18,7 +18,9 @@ */ import tech.libeufin.common.* -import kotlin.test.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails class SubjectTest { fun assertFailsMsg(msg: String, lambda: () -> Unit) { @@ -27,7 +29,7 @@ class SubjectTest { } @Test - fun parse() { + fun parseIncoming() { val key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; val other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; @@ -44,7 +46,7 @@ class SubjectTest { } else { IncomingSubject.Kyc(EddsaPublicKey(key)) } - + // Check succeed if standard or mixed for (case in sequenceOf(standard, mixed)) { for (test in sequenceOf( @@ -104,7 +106,7 @@ class SubjectTest { )) { assertEquals(key, parseIncomingSubject(case)) } - + // Check failure if malformed or missing for (case in sequenceOf( "does not contain any reserve", // Check fail if none @@ -142,7 +144,7 @@ class SubjectTest { /** Test parsing logic using real use case */ @Test - fun real() { + fun realIncoming() { // Good reserve cases for ((subject, key) in sequenceOf( "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60" to "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", @@ -166,4 +168,29 @@ class SubjectTest { ) } } -} -\ No newline at end of file + + @Test + fun outgoing() { + val key = ShortHashCode.rand() + + run { + // Without metadata + val subject = "$key http://exchange.example.com/" + val parsed = parseOutgoingSubject(subject) + assertEquals(parsed, Triple(key, BaseURL.parse("http://exchange.example.com/"), null)) + assertEquals(subject, fmtOutgoingSubject(parsed.first, parsed.second, parsed.third)) + } + + run { + // With metadata + val subject = + "Accounting:id.42 $key http://exchange.example.com/" + val parsed = parseOutgoingSubject(subject) + assertEquals( + parsed, + Triple(key, BaseURL.parse("http://exchange.example.com/"), "Accounting:id.42") + ) + assertEquals(subject, fmtOutgoingSubject(parsed.first, parsed.second, parsed.third)) + } + } +} diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -48,10 +48,10 @@ suspend fun registerOutgoingPayment( db: Database, payment: OutgoingPayment ): OutgoingRegistrationResult { - val metadata: Pair<ShortHashCode, BaseURL>? = payment.subject?.let { + val metadata: Triple<ShortHashCode, BaseURL, String?>? = payment.subject?.let { runCatching { parseOutgoingSubject(it) }.getOrNull() } - val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second) + val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second, metadata?.third) if (result.new) { if (result.initiated) logger.info("$payment") diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -105,7 +105,7 @@ private suspend fun submitAll(client: EbicsClient, requireAck: Boolean, cfg: Nex }.fold( onSuccess = { orderId -> db.initiated.batchSubmissionSuccess(batch.id, Instant.now(), orderId) - val transactions = batch.payments.map { it.endToEndId }.joinToString(",") + val transactions = batch.payments.joinToString(",") { it.endToEndId } if (instantDebitOrder == null) { logger.info("Batch ${batch.messageId} submitted as order $orderId: $transactions") } else { diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -44,8 +44,7 @@ class ExchangeDAO(private val db: Database) { JOIN incoming_transactions USING(incoming_transaction_id) WHERE """, "incoming_transaction_id") { - val type = it.getEnum<IncomingType>("type") - when (type) { + when (it.getEnum<IncomingType>("type")) { IncomingType.reserve -> IncomingReserveTransaction( row_id = it.getLong("incoming_transaction_id"), date = it.getTalerTimestamp("execution_time"), @@ -81,6 +80,7 @@ class ExchangeDAO(private val db: Database) { ,credit_payto ,wtid ,exchange_base_url + ,metadata FROM talerable_outgoing_transactions JOIN outgoing_transactions USING(outgoing_transaction_id) WHERE @@ -92,7 +92,8 @@ class ExchangeDAO(private val db: Database) { debit_fee = it.getAmount("debit_fee", db.currency).notZeroOrNull(), credit_account = it.getString("credit_payto"), wtid = ShortHashCode(it.getBytes("wtid")), - exchange_base_url = it.getString("exchange_base_url") + exchange_base_url = it.getString("exchange_base_url"), + metadata = it.getString("metadata"), ) } @@ -119,17 +120,21 @@ class ExchangeDAO(private val db: Database) { FROM taler_transfer ( ?, ?, ?, (?,?)::taler_amount, - ?, ?, ?, ? + ?, ?, ?, ?, ? ) """ ) { - val subject = "${req.wtid} ${req.exchange_base_url.url}" + var subject = "${req.wtid} ${req.exchange_base_url.url}" + if (req.metadata != null) { + subject += " ${req.metadata}" + } bind(req.request_uid) bind(req.wtid) bind(subject) bind(req.amount) bind(req.exchange_base_url.toString()) + bind(req.metadata) bind(req.credit_account.toString()) bind(endToEndId) bind(timestamp) @@ -153,6 +158,7 @@ class ExchangeDAO(private val db: Database) { SELECT wtid ,exchange_base_url + ,metadata ,(amount).val AS amount_val ,(amount).frac AS amount_frac ,credit_payto @@ -171,6 +177,7 @@ class ExchangeDAO(private val db: Database) { status_msg = it.getString("status_msg"), amount = it.getAmount("amount", db.currency), origin_exchange_url = it.getString("exchange_base_url"), + metadata = it.getString("metadata"), wtid = ShortHashCode(it.getBytes("wtid")), credit_account = it.getString("credit_payto"), timestamp = it.getTalerTimestamp("initiation_time"), diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -28,9 +28,9 @@ import java.time.Instant /** Data access logic for initiated outgoing payments */ class InitiatedDAO(private val db: Database) { private val UNSETTLED_FILTER = - "status NOT IN (${SubmissionState.SETTLED.map { "'$it'" }.joinToString(",")})" + "status NOT IN (${SubmissionState.SETTLED.joinToString(",") { "'$it'" }})" private val PENDING_FILTER = - "status IN (${SubmissionState.PENDING.map { "'$it'" }.joinToString(",")})" + "status IN (${SubmissionState.PENDING.joinToString(",") { "'$it'" }})" /** Outgoing payments initiation result */ sealed interface PaymentInitiationResult { @@ -351,7 +351,7 @@ class InitiatedDAO(private val db: Database) { payments = emptyList() ) } - }.map { it.id to Pair(it, mutableListOf<InitiatedPayment>()) }.toMap() + }.associate { it.id to Pair(it, mutableListOf<InitiatedPayment>()) } // Then load transactions tx.withStatement( diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -40,10 +40,11 @@ class PaymentDAO(private val db: Database) { payment: OutgoingPayment, wtid: ShortHashCode?, baseUrl: BaseURL?, + metadata: String? ): OutgoingRegistrationResult = db.serializable( """ SELECT out_tx_id, out_initiated, out_found - FROM register_outgoing((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,?,?) + FROM register_outgoing((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,?,?,?) """ ) { val executionTime = payment.executionTime.micros() @@ -58,6 +59,7 @@ class PaymentDAO(private val db: Database) { bind(payment.id.acctSvcrRef) bind(wtid) bind(baseUrl?.url?.toString()) + bind(metadata) one { OutgoingRegistrationResult( diff --git a/libeufin-nexus/src/test/kotlin/DatabaseTest.kt b/libeufin-nexus/src/test/kotlin/DatabaseTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -116,7 +116,7 @@ class OutgoingPaymentsTest { assertEquals(OutgoingRegistrationResult(id = first.id, initiated = false, new = true), first) assertEquals( OutgoingRegistrationResult(id = first.id, initiated = false, new = false), - db.payment.registerOutgoing(pay, null, null) + db.payment.registerOutgoing(pay, null, null, null) ) } db.checkOutCount(nbIncoming = 8, nbTalerable = 3) diff --git a/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -56,6 +56,29 @@ class WireGatewayApiTest { client.postA("/taler-wire-gateway/transfer") { json(valid_req) }.assertOk() + + val with_metadata = obj(valid_req) { + "request_uid" to HashCode.rand() + "metadata" to "ID" + "wtid" to ShortHashCode.rand() + } + client.postA("/taler-wire-gateway/transfer") { + json(with_metadata) + }.assertOk() + client.postA("/taler-wire-gateway/transfer") { + json(with_metadata) + }.assertOk() + + // Malformed metadata + listOf("bad_id", "bad id", "bad@id.com", "A".repeat(41)).forEach { + client.postA("/taler-wire-gateway/transfer") { + json(valid_req) { + "request_uid" to HashCode.rand() + "metadata" to it + "wtid" to ShortHashCode.rand() + } + }.assertBadRequest() + } // Trigger conflict due to reused request_uid client.postA("/taler-wire-gateway/transfer") { @@ -155,9 +178,23 @@ class WireGatewayApiTest { assertEquals(TransferStatusState.pending, tx.status) assertEquals(TalerAmount("CHF:55"), tx.amount) assertEquals("http://exchange.example.com/", tx.origin_exchange_url) + assertNull(tx.metadata) assertEquals(wtid, tx.wtid) assertEquals(resp.timestamp, tx.timestamp) } + + client.postA("/taler-wire-gateway/transfer") { + json(valid_req) { + "request_uid" to HashCode.rand() + "metadata" to "ID" + "wtid" to ShortHashCode.rand() + } + }.assertOkJson<TransferResponse> { + client.getA("/taler-wire-gateway/transfers/${it.row_id}") + .assertOkJson<TransferStatus> { tx -> + assertEquals(tx.metadata, "ID") + } + } // Check unknown transaction client.getA("/taler-wire-gateway/transfers/42") @@ -254,6 +291,9 @@ class WireGatewayApiTest { registered = listOf( // Transfer using raw bank transaction logic { talerableOut(db) }, + + // And with metadata + { talerableOut(db, "CON.ID") } ), ignored = listOf( // Ignore pending transfers @@ -269,6 +309,20 @@ class WireGatewayApiTest { { registerOutgoingPayment(db, genOutPay("ignored")) }, ) ) + println(client.getA("/taler-wire-gateway/history/outgoing?limit=2") + .assertOkJson<OutgoingHistory>() + .outgoing_transactions + .map { it.amount.toString() to it.metadata }) + assertContentEquals( + client.getA("/taler-wire-gateway/history/outgoing?limit=2") + .assertOkJson<OutgoingHistory>() + .outgoing_transactions + .map { it.amount.toString() to it.metadata } + ,listOf( + "CHF:44" to null, + "CHF:44" to "CON.ID", + ) + ) } suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) { diff --git a/libeufin-nexus/src/test/kotlin/helpers.kt b/libeufin-nexus/src/test/kotlin/helpers.kt @@ -146,15 +146,16 @@ suspend fun ApplicationTestBuilder.addKyc(amount: String) { } /** Register a talerable outgoing transaction */ -suspend fun talerableOut(db: Database) { +suspend fun talerableOut(db: Database, metadata: String? = null) { val wtid = EddsaPublicKey.randEdsaKey() - registerOutgoingPayment(db, genOutPay("$wtid http://exchange.example.com/")) + registerOutgoingPayment(db, genOutPay(fmtOutgoingSubject(wtid, BaseURL.parse("http://exchange.example.com/"), metadata))) } /** Register a talerable reserve incoming transaction */ suspend fun talerableIn(db: Database, amount: String = "CHF:44") { val reserve_pub = EddsaPublicKey.randEdsaKey() - registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), + registerIncomingPayment( + db, NexusIngestConfig.default(AccountType.exchange), genInPay("test with $reserve_pub reserve pub", amount) ) } @@ -178,7 +179,8 @@ suspend fun talerableCompletedIn(db: Database) { /** Register a talerable KYC incoming transaction */ suspend fun talerableKycIn(db: Database, amount: String = "CHF:44") { val account_pub = EddsaPublicKey.randEdsaKey() - registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), + registerIncomingPayment( + db, NexusIngestConfig.default(AccountType.exchange), genInPay("test with KYC:$account_pub account pub", amount) ) }