commit 7d22e626f9e67a4f6ca0102f45d0b0c01a907c60
parent 324ca09045c85b352f1b441f31d5e53e9113a6f4
Author: Christian Grothoff <christian@grothoff.org>
Date: Sun, 22 Feb 2026 19:40:09 +0100
fix #10176 and #11121
Diffstat:
7 files changed, 259 insertions(+), 114 deletions(-)
diff --git a/src/backend/taler-merchant-httpd_private-post-transfers.c b/src/backend/taler-merchant-httpd_private-post-transfers.c
@@ -1,6 +1,6 @@
/*
This file is part of TALER
- (C) 2014-2023, 2025 Taler Systems SA
+ (C) 2014-2023, 2025, 2026 Taler Systems SA
TALER 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
@@ -57,6 +57,9 @@ TMH_private_post_transfers (const struct TMH_RequestHandler *rh,
};
enum GNUNET_GenericReturnValue res;
enum GNUNET_DB_QueryStatus qs;
+ bool no_instance;
+ bool no_account;
+ bool conflict;
res = TALER_MHD_parse_json_data (connection,
hc->request_body,
@@ -72,81 +75,64 @@ TMH_private_post_transfers (const struct TMH_RequestHandler *rh,
exchange_url);
/* Check if transfer data is in database, if not, add it. */
- for (unsigned int retry = 0; retry<MAX_RETRIES; retry++)
+ qs = TMH_db->insert_transfer (TMH_db->cls,
+ hc->instance->settings.id,
+ exchange_url,
+ &wtid,
+ &amount,
+ payto_uri,
+ 0 /* no bank serial known! */,
+ &no_instance,
+ &no_account,
+ &conflict);
+ switch (qs)
{
- TMH_db->preflight (TMH_db->cls);
- if (GNUNET_OK !=
- TMH_db->start (TMH_db->cls,
- "post-transfers"))
- {
- GNUNET_break (0);
- return TALER_MHD_reply_with_error (connection,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_START_FAILED,
- "transfer");
- }
- qs = TMH_db->insert_transfer (TMH_db->cls,
- hc->instance->settings.id,
- exchange_url,
- &wtid,
- &amount,
- payto_uri,
- 0 /* no bank serial known! */);
- switch (qs)
- {
- case GNUNET_DB_STATUS_HARD_ERROR:
- GNUNET_break (0);
- TMH_db->rollback (TMH_db->cls);
- return TALER_MHD_reply_with_error (connection,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_STORE_FAILED,
- "insert_transfer");
- case GNUNET_DB_STATUS_SOFT_ERROR:
- TMH_db->rollback (TMH_db->cls);
- continue;
- case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
- /* Must mean the bank account is unknown! */
- TMH_db->rollback (TMH_db->cls);
- return TALER_MHD_reply_with_error (
- connection,
- MHD_HTTP_CONFLICT,
- TALER_EC_MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_SUBMISSION,
- payto_uri.full_payto);
- case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
- break;
- }
- {
- struct GNUNET_DB_EventHeaderP es = {
- .size = htons (sizeof (es)),
- .type = htons (TALER_DBEVENT_MERCHANT_WIRE_TRANSFER_CONFIRMED)
- };
-
- TMH_db->event_notify (TMH_db->cls,
- &es,
- NULL,
- 0);
- }
- qs = TMH_db->commit (TMH_db->cls);
- switch (qs)
- {
- case GNUNET_DB_STATUS_HARD_ERROR:
- GNUNET_break (0);
- TMH_db->rollback (TMH_db->cls);
- return TALER_MHD_reply_with_error (connection,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_COMMIT_FAILED,
- NULL);
- case GNUNET_DB_STATUS_SOFT_ERROR:
- TMH_db->rollback (TMH_db->cls);
- continue;
- case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
- case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
- GNUNET_log (GNUNET_ERROR_TYPE_INFO,
- "post-transfer committed successfully\n");
- break;
- }
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "insert_transfer");
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "insert_transfer");
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ GNUNET_assert (0); /* should be impossible */
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
break;
}
+ if (no_instance)
+ {
+ /* should be only possible if instance was concurrently deleted,
+ that's so theoretical we rather log as error... */
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN,
+ hc->instance->settings.id);
+ }
+ if (no_account)
+ {
+ GNUNET_break_op (0);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_GENERIC_ACCOUNT_UNKNOWN,
+ payto_uri.full_payto);
+ }
+ if (conflict)
+ {
+ GNUNET_break_op (0);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_SUBMISSION,
+ NULL);
+ }
return TALER_MHD_reply_static (connection,
MHD_HTTP_NO_CONTENT,
NULL,
diff --git a/src/backend/taler-merchant-wirewatch.c b/src/backend/taler-merchant-wirewatch.c
@@ -29,6 +29,7 @@
#include "taler_merchantdb_lib.h"
#include "taler_merchantdb_plugin.h"
+
/**
* Timeout for the bank interaction. Rather long as we should do long-polling
* and do not want to wake up too often.
@@ -353,6 +354,9 @@ credit_cb (
enum GNUNET_DB_QueryStatus qs;
char *exchange_url;
struct TALER_WireTransferIdentifierRawP wtid;
+ bool no_instance;
+ bool no_account;
+ bool conflict;
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
"Received wire transfer `%s' over %s\n",
@@ -379,7 +383,11 @@ credit_cb (
&wtid,
&details->amount,
details->credit_account_uri,
- serial_id);
+ serial_id,
+ &no_instance,
+ &no_account,
+ &conflict);
+
GNUNET_free (exchange_url);
if (qs < 0)
{
@@ -388,19 +396,32 @@ credit_cb (
w->hh = NULL;
return GNUNET_SYSERR;
}
- /* Success => reset back-off timer! */
- w->delay = GNUNET_TIME_UNIT_ZERO;
+ if (no_instance)
+ {
+ GNUNET_break (0);
+ GNUNET_SCHEDULER_shutdown ();
+ w->hh = NULL;
+ return GNUNET_SYSERR;
+ }
+ if (no_account)
+ {
+ GNUNET_break (0);
+ GNUNET_SCHEDULER_shutdown ();
+ w->hh = NULL;
+ return GNUNET_SYSERR;
+ }
+ if (conflict)
{
- struct GNUNET_DB_EventHeaderP es = {
- .size = htons (sizeof (es)),
- .type = htons (TALER_DBEVENT_MERCHANT_WIRE_TRANSFER_CONFIRMED)
- };
-
- db_plugin->event_notify (db_plugin->cls,
- &es,
- NULL,
- 0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Fatal: new wire transfer #%llu has same WTID but different amount %s compared to a previous transfer\n",
+ (unsigned long long) serial_id,
+ TALER_amount2s (&details->amount));
+ GNUNET_SCHEDULER_shutdown ();
+ w->hh = NULL;
+ return GNUNET_SYSERR;
}
+ /* Success => reset back-off timer! */
+ w->delay = GNUNET_TIME_UNIT_ZERO;
}
w->start_row = serial_id;
return GNUNET_OK;
diff --git a/src/backenddb/pg_insert_transfer.c b/src/backenddb/pg_insert_transfer.c
@@ -34,50 +34,47 @@ TMH_PG_insert_transfer (
const struct TALER_WireTransferIdentifierRawP *wtid,
const struct TALER_Amount *credit_amount,
struct TALER_FullPayto payto_uri,
- uint64_t bank_serial_id)
+ uint64_t bank_serial_id,
+ bool *no_instance,
+ bool *no_account,
+ bool *conflict)
{
struct PostgresClosure *pg = cls;
struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get ();
struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (instance_id),
GNUNET_PQ_query_param_string (exchange_url),
GNUNET_PQ_query_param_auto_from_type (wtid),
TALER_PQ_query_param_amount_with_currency (pg->conn,
credit_amount),
GNUNET_PQ_query_param_string (payto_uri.full_payto),
- GNUNET_PQ_query_param_string (instance_id),
0 == bank_serial_id
? GNUNET_PQ_query_param_null ()
: GNUNET_PQ_query_param_uint64 (&bank_serial_id),
GNUNET_PQ_query_param_absolute_time (&now),
GNUNET_PQ_query_param_end
};
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_bool ("no_instance",
+ no_instance),
+ GNUNET_PQ_result_spec_bool ("no_account",
+ no_account),
+ GNUNET_PQ_result_spec_bool ("conflict",
+ conflict),
+ GNUNET_PQ_result_spec_end
+ };
check_connection (pg);
- // FIXME-#10176: if transfer with matching exchange_url, wtid and credit_amount
- // and account_serial exists already AND where bank_serial_id is NULL
- // and if our bank_serial_id is NOT NULL, then maybe UPDATE instead?
- // (user may have switched from manual import to automatic import,
- // and now we may be duplicating all the records, which would be bad).
PREPARE (pg,
"insert_transfer",
- "INSERT INTO merchant_transfers"
- "(exchange_url"
- ",wtid"
- ",credit_amount"
- ",account_serial"
- ",bank_serial_id"
- ",execution_time)"
- "SELECT"
- " $1, $2, $3, account_serial, $6, $7"
- " FROM merchant_accounts"
- " WHERE REGEXP_REPLACE(payto_uri,'\\?.*','')"
- " =REGEXP_REPLACE($4,'\\?.*','')"
- " AND merchant_serial="
- " (SELECT merchant_serial"
- " FROM merchant_instances"
- " WHERE merchant_id=$5)"
- " ON CONFLICT DO NOTHING;");
- return GNUNET_PQ_eval_prepared_non_select (pg->conn,
- "insert_transfer",
- params);
+ "SELECT "
+ " out_no_instance AS no_instance"
+ " ,out_no_account AS no_account"
+ " ,out_conflict AS conflict"
+ " FROM merchant_do_insert_transfer"
+ " ($1, $2, $3, $4, $5, $6, $7);");
+ return GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "insert_transfer",
+ params,
+ rs);
}
diff --git a/src/backenddb/pg_insert_transfer.h b/src/backenddb/pg_insert_transfer.h
@@ -27,6 +27,9 @@
/**
* Insert information about a wire transfer the merchant has received.
+ * Idempotent, will do nothing if the same wire transfer is already known.
+ * Updates the @a bank_serial_id if an equivalent transfer exists where
+ * the @a bank_serial_id was set to 0 (unknown).
*
* @param cls closure
* @param instance_id the instance that received the transfer
@@ -36,6 +39,9 @@
* @param payto_uri what is the merchant's bank account that received the transfer
* @param bank_serial_id unique ID for the wire transfer at the bank,
* 0 for "NULL" if none is known due to manual import
+ * @param[out] no_instance instance unknown to backend
+ * @param[out] no_account bank account with @a payto_uri unknown to backend
+ * @param[out] conflict transfer with same @a wtid but different @a credit_amount exists
* @return transaction status
*/
enum GNUNET_DB_QueryStatus
@@ -46,7 +52,10 @@ TMH_PG_insert_transfer (
const struct TALER_WireTransferIdentifierRawP *wtid,
const struct TALER_Amount *credit_amount,
struct TALER_FullPayto payto_uri,
- uint64_t bank_serial_id);
+ uint64_t bank_serial_id,
+ bool *no_instance,
+ bool *no_account,
+ bool *conflict);
#endif
diff --git a/src/backenddb/pg_insert_transfer.sql b/src/backenddb/pg_insert_transfer.sql
@@ -0,0 +1,122 @@
+--
+-- 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/>
+--
+
+DROP FUNCTION IF EXISTS merchant_do_insert_transfer;
+CREATE FUNCTION merchant_do_insert_transfer (
+ IN in_instance_id TEXT,
+ IN in_exchange_url TEXT,
+ IN in_wtid BYTEA,
+ IN in_credit_amount taler_amount_currency,
+ IN in_credited_account_payto TEXT,
+ IN in_bank_serial_id INT8, -- can be NULL if unknown
+ IN in_execution_time INT8,
+ OUT out_no_instance BOOL,
+ OUT out_no_account BOOL,
+ OUT out_conflict BOOL)
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ my_merchant_id INT8;
+ my_account_serial INT8;
+ my_record RECORD;
+ my_bank_serial_id INT8;
+ my_credit_amount taler_amount_currency;
+BEGIN
+
+out_conflict=FALSE;
+
+-- Which instance are we using?
+SELECT merchant_serial
+ INTO my_merchant_id
+ FROM merchant_instances
+ WHERE merchant_id=in_instance_id;
+IF NOT FOUND
+THEN
+ out_no_instance=TRUE;
+ out_no_account=TRUE; -- also true...
+ RETURN;
+END IF;
+out_no_instance=FALSE;
+
+SELECT account_serial
+ INTO my_account_serial
+ FROM merchant_accounts
+ WHERE REGEXP_REPLACE(payto_uri,
+ '\\?.*','')
+ =REGEXP_REPLACE(in_credited_account_payto,
+ '\\?.*','')
+ AND merchant_serial=my_merchant_id;
+IF NOT FOUND
+THEN
+ out_no_account=TRUE;
+ RETURN;
+END IF;
+out_no_account=FALSE;
+
+SELECT bank_serial_id
+ ,credit_amount
+ INTO my_record
+ FROM merchant_transfers
+ WHERE wtid=in_wtid
+ AND account_serial=my_account_serial
+ AND exchange_url=in_exchange_url;
+IF NOT FOUND
+THEN
+ INSERT INTO merchant_transfers
+ (exchange_url
+ ,wtid
+ ,credit_amount
+ ,account_serial
+ ,bank_serial_id
+ ,execution_time
+ ) VALUES
+ (in_exchange_url
+ ,in_wtid
+ ,in_credit_amount
+ ,my_account_serial
+ ,in_bank_serial_id
+ ,in_execution_time);
+ -- Do notify on TALER_DBEVENT_MERCHANT_WIRE_TRANSFER_CONFIRMED
+ NOTIFY XJ5N652FA4TBS2WXGY3S1FMPMQYTD8KAZA9B7HW9JWJ4PZ2DB852G;
+ RETURN;
+END IF;
+
+my_bank_serial_id = my_record.bank_serial_id;
+my_credit_amount = my_record.credit_amount;
+
+IF ( (in_credit_amount.val != my_credit_amount.val) OR
+ (in_credit_amount.frac != my_credit_amount.frac) OR
+ (in_credit_amount.curr != my_credit_amount.curr) )
+THEN
+ out_conflict = TRUE; -- amounts differ, not OK!
+ RETURN;
+END IF;
+
+IF ( (my_bank_serial_id IS NULL) AND
+ (in_bank_serial_id IS NOT NULL) )
+THEN
+ -- We learned the bank_bank_serial_id, update that
+ UPDATE merchant_transfers
+ SET bank_serial_id=in_bank_serial_id
+ WHERE wtid=in_wtid
+ AND account_serial=my_account_serial
+ AND exchange_url=in_exchange_url;
+ RETURN;
+END IF;
+
+-- idempotent request, success.
+
+END $$;
diff --git a/src/backenddb/procedures.sql.in b/src/backenddb/procedures.sql.in
@@ -34,6 +34,7 @@ SET search_path TO merchant;
#include "pg_update_money_pot.sql"
#include "pg_increment_money_pots.sql"
#include "pg_account_kyc_get_status.sql"
+#include "pg_insert_transfer.sql"
DROP PROCEDURE IF EXISTS merchant_do_gc;
CREATE PROCEDURE merchant_do_gc(in_now INT8)
diff --git a/src/include/taler_merchantdb_plugin.h b/src/include/taler_merchantdb_plugin.h
@@ -3757,6 +3757,9 @@ struct TALER_MERCHANTDB_Plugin
/**
* Insert information about a wire transfer the merchant has received.
+ * Idempotent, will do nothing if the same wire transfer is already known.
+ * Updates the @a bank_serial_id if an equivalent transfer exists where
+ * the @a bank_serial_id was set to 0 (unknown).
*
* @param cls closure
* @param instance_id instance to lookup the order from
@@ -3765,6 +3768,9 @@ struct TALER_MERCHANTDB_Plugin
* @param credit_amount how much did we receive
* @param payto_uri what is the merchant's bank account that received the transfer
* @param bank_serial_id bank serial transfer ID, 0 for none (use NULL in DB!)
+ * @param[out] no_instance instance unknown to backend
+ * @param[out] no_account bank account with @a payto_uri unknown to backend
+ * @param[out] conflict transfer with same @a wtid but different @a credit_amount exists
* @return transaction status
*/
enum GNUNET_DB_QueryStatus
@@ -3775,7 +3781,10 @@ struct TALER_MERCHANTDB_Plugin
const struct TALER_WireTransferIdentifierRawP *wtid,
const struct TALER_Amount *credit_amount,
struct TALER_FullPayto payto_uri,
- uint64_t bank_serial_id);
+ uint64_t bank_serial_id,
+ bool *no_instance,
+ bool *no_account,
+ bool *conflict);
/**