commit 649e8a109fadcf049643e2f1e736cfeb7c825d23
parent ab7ba842d024a75c3e4cbc416591eb419f367d0a
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 5 Aug 2026 22:53:50 +0200
expose non-aggregation reasons to auditor via append-only table, and check non-aggregation reasons in auditor
Diffstat:
46 files changed, 3531 insertions(+), 15 deletions(-)
diff --git a/src/auditor/meson.build b/src/auditor/meson.build
@@ -238,6 +238,7 @@ taler_auditor_httpd_SOURCES = [
'taler-auditor-httpd_patch-generic-suppressed.c',
'taler-auditor-httpd_delete-generic.c',
'taler-auditor-httpd_get-monitoring-bad-sig-losses.c',
+ 'taler-auditor-httpd_get-monitoring-aml-holds.c',
'taler-auditor-httpd_get-monitoring-closure-lags.c',
'taler-auditor-httpd_get-monitoring-progress.c',
'taler-auditor-httpd_get-monitoring-early-aggregation.c',
diff --git a/src/auditor/taler-auditor-httpd.c b/src/auditor/taler-auditor-httpd.c
@@ -46,6 +46,7 @@
#include \
"taler-auditor-httpd_get-monitoring-reserve-balance-insufficient-inconsistency.h"
#include "taler-auditor-httpd_get-monitoring-bad-sig-losses.h"
+#include "taler-auditor-httpd_get-monitoring-aml-holds.h"
#include "taler-auditor-httpd_get-monitoring-closure-lags.h"
#include "taler-auditor-httpd_mhd.h"
#include "taler-auditor-httpd.h"
@@ -485,6 +486,32 @@ handle_mhd_request (void *cls,
.response_code = MHD_HTTP_OK,
.requires_auth = true,
.table = TALER_AUDITORDB_BAD_SIG_LOSSES },
+ { .url = "/monitoring/aml-holds",
+ .method = MHD_HTTP_METHOD_GET,
+ .mime_type = "application/json",
+ .data = NULL,
+ .data_size = 0,
+ .handler = &TAH_get_monitoring_aml_holds,
+ .response_code = MHD_HTTP_OK,
+ .requires_auth = true },
+ { .url = "/monitoring/aml-holds",
+ .method = MHD_HTTP_METHOD_DELETE,
+ .mime_type = "application/json",
+ .data = NULL,
+ .data_size = 0,
+ .handler = &TAH_delete_generic,
+ .response_code = MHD_HTTP_OK,
+ .requires_auth = true,
+ .table = TALER_AUDITORDB_AML_HOLDS },
+ { .url = "/monitoring/aml-holds",
+ .method = MHD_HTTP_METHOD_PATCH,
+ .mime_type = "application/json",
+ .data = NULL,
+ .data_size = 0,
+ .handler = &TAH_patch_generic_suppressed,
+ .response_code = MHD_HTTP_OK,
+ .requires_auth = true,
+ .table = TALER_AUDITORDB_AML_HOLDS },
{ .url = "/monitoring/closure-lags",
.method = MHD_HTTP_METHOD_GET,
.mime_type = "application/json",
diff --git a/src/auditor/taler-auditor-httpd_get-monitoring-aml-holds.c b/src/auditor/taler-auditor-httpd_get-monitoring-aml-holds.c
@@ -0,0 +1,167 @@
+/*
+ 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/>
+ */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_json_lib.h>
+#include <jansson.h>
+#include <microhttpd.h>
+#include <pthread.h>
+#include "taler/taler_json_lib.h"
+#include "taler/taler_mhd_lib.h"
+#include "exchangedb_lib.h"
+#include "taler-auditor-httpd.h"
+#include "taler-auditor-httpd_get-monitoring-aml-holds.h"
+#define TALER_AUDITORDB_AUDITOR_AML_HOLD_RESULT_CLOSURE json_t
+#include "auditor-database/iterate_auditor_aml_holds.h"
+#include "auditor-database/preflight.h"
+
+
+/**
+ * Convert the reason the exchange gave for not making a transfer into the
+ * string the REST API uses for it.
+ *
+ * @param reason an `enum TALER_EXCHANGEDB_DeferralReason` value
+ * @return human-readable name of @a reason
+ */
+static const char *
+reason2s (uint32_t reason)
+{
+ switch ((enum TALER_EXCHANGEDB_DeferralReason) reason)
+ {
+ case TALER_EXCHANGEDB_DR_NONE:
+ return "NONE";
+ case TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL:
+ return "AMOUNT_TOO_SMALL";
+ case TALER_EXCHANGEDB_DR_KYC:
+ return "KYC";
+ }
+ /* the exchange wrote a reason this auditor does not know; say so rather
+ than guess, the row is still a hold either way */
+ GNUNET_break (0);
+ return "INVALID";
+}
+
+
+/**
+ * Add an AML hold to the list.
+ *
+ * @param[in,out] list a `json_t *` array to extend
+ * @param ah the hold
+ * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop iterating
+ */
+static enum GNUNET_GenericReturnValue
+process_aml_holds (
+ json_t *list,
+ const struct TALER_AUDITORDB_AmlHold *ah)
+{
+ json_t *obj;
+
+ obj = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_uint64 ("row_id",
+ ah->row_id),
+ GNUNET_JSON_pack_data_auto ("wtid",
+ &ah->wtid),
+ GNUNET_JSON_pack_data_auto ("wire_target_h_payto",
+ &ah->wire_target_h_payto),
+ TALER_JSON_pack_full_payto ("account",
+ ah->account),
+ TALER_JSON_pack_amount ("amount",
+ &ah->amount),
+ GNUNET_JSON_pack_string ("deferral_reason",
+ reason2s (ah->deferral_reason)),
+ GNUNET_JSON_pack_uint64 ("legitimization_measure_serial_id",
+ ah->legitimization_measure_serial_id),
+ TALER_JSON_pack_time_abs_human ("first_seen",
+ ah->creation_date),
+ GNUNET_JSON_pack_bool ("suppressed",
+ ah->suppressed)
+ );
+ GNUNET_break (0 ==
+ json_array_append_new (list,
+ obj));
+ return GNUNET_OK;
+}
+
+
+enum MHD_Result
+TAH_get_monitoring_aml_holds (
+ struct TAH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ void **connection_cls,
+ const char *upload_data,
+ size_t *upload_data_size,
+ const char *const args[])
+{
+ json_t *ja;
+ enum GNUNET_DB_QueryStatus qs;
+ int64_t limit = -20;
+ uint64_t offset;
+ bool return_suppressed = false;
+
+ if (GNUNET_SYSERR ==
+ TALER_AUDITORDB_preflight (TAH_apg))
+ {
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_SETUP_FAILED,
+ NULL);
+ }
+ TALER_MHD_parse_request_snumber (connection,
+ "limit",
+ &limit);
+ if (limit < 0)
+ offset = INT64_MAX;
+ else
+ offset = 0;
+ TALER_MHD_parse_request_number (connection,
+ "offset",
+ &offset);
+ {
+ const char *ret_s
+ = MHD_lookup_connection_value (connection,
+ MHD_GET_ARGUMENT_KIND,
+ "return_suppressed");
+ if (ret_s != NULL && strcmp (ret_s, "true") == 0)
+ {
+ return_suppressed = true;
+ }
+ }
+ ja = json_array ();
+ GNUNET_break (NULL != ja);
+ qs = TALER_AUDITORDB_iterate_auditor_aml_holds (
+ TAH_apg,
+ limit,
+ offset,
+ return_suppressed,
+ &process_aml_holds,
+ ja);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_HARD_ERROR == qs);
+ json_decref (ja);
+ TALER_LOG_WARNING (
+ "Failed to handle GET /monitoring/aml-holds\n");
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "iterate_auditor_aml_holds");
+ }
+ return TALER_MHD_REPLY_JSON_PACK (
+ connection,
+ MHD_HTTP_OK,
+ GNUNET_JSON_pack_array_steal ("aml_holds",
+ ja));
+}
diff --git a/src/auditor/taler-auditor-httpd_get-monitoring-aml-holds.h b/src/auditor/taler-auditor-httpd_get-monitoring-aml-holds.h
@@ -0,0 +1,46 @@
+/*
+ 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/>
+ */
+
+
+#ifndef TALER_AUDITOR_HTTPD_GET_MONITORING_AML_HOLDS_H
+#define TALER_AUDITOR_HTTPD_GET_MONITORING_AML_HOLDS_H
+
+#include <gnunet/gnunet_util_lib.h>
+#include <microhttpd.h>
+#include "taler-auditor-httpd.h"
+
+/**
+ * Handle a "/monitoring/aml-holds" request.
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] connection_cls the connection's closure (can be updated)
+ * @param upload_data upload data
+ * @param[in,out] upload_data_size number of bytes (left) in @a upload_data
+ * @param args NULL-terminated array of remaining parts of the URI broken up at '/'
+ * @return MHD result code
+ */
+enum MHD_Result
+TAH_get_monitoring_aml_holds (
+ struct TAH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ void **connection_cls,
+ const char *upload_data,
+ size_t *upload_data_size,
+ const char *const args[]);
+
+
+#endif
diff --git a/src/auditor/taler-auditor-sync.c b/src/auditor/taler-auditor-sync.c
@@ -322,6 +322,8 @@ rt_name (enum TALER_EXCHANGEDB_ReplicatedTable rt)
return "wire_out";
case TALER_EXCHANGEDB_RT_AGGREGATION_TRACKING:
return "aggregation_tracking";
+ case TALER_EXCHANGEDB_RT_AGGREGATION_DEFERRALS:
+ return "aggregation_deferrals";
case TALER_EXCHANGEDB_RT_WIRE_FEE:
return "wire_fee";
case TALER_EXCHANGEDB_RT_GLOBAL_FEE:
@@ -450,6 +452,9 @@ static struct Table tables[] = {
{ .rt = TALER_EXCHANGEDB_RT_REFUNDS},
{ .rt = TALER_EXCHANGEDB_RT_WIRE_OUT},
{ .rt = TALER_EXCHANGEDB_RT_AGGREGATION_TRACKING},
+ /* after batch_deposits: the foreign key that ties a deferral's lifetime to
+ the deposits it is about is enforced here too */
+ { .rt = TALER_EXCHANGEDB_RT_AGGREGATION_DEFERRALS},
{ .rt = TALER_EXCHANGEDB_RT_WIRE_FEE},
{ .rt = TALER_EXCHANGEDB_RT_GLOBAL_FEE},
{ .rt = TALER_EXCHANGEDB_RT_RECOUP},
diff --git a/src/auditor/taler-helper-auditor-transfer.c b/src/auditor/taler-helper-auditor-transfer.c
@@ -18,6 +18,23 @@
* @brief audits that deposits past due date are
* aggregated and have a matching wire transfer
* database.
+ *
+ * Three checks, each with its own cursor over the exchange's tables:
+ *
+ * 1) every batch deposit must eventually be aggregated (`total_amount_lag`),
+ * 2) nothing may be aggregated before there is a deposit to justify it
+ * (`total_early_aggregation`),
+ * 3) every aggregation must eventually turn into a wire transfer.
+ *
+ * The third is what keeps "the exchange paid the merchant" apart from "the
+ * exchange decided what it owes and then sat on the money": an
+ * `aggregation_tracking` row is not proof of payment, a `wire_out` row is.
+ * What is still owed is split by the reason the exchange gave for not paying
+ * -- `total_aml_hold` for an open KYC requirement, `total_small_aggregate` for
+ * an aggregate that does not yet cover its wire fee, and `total_transfer_lag`
+ * for a transfer the exchange never gave any reason for -- and each held
+ * transfer is listed in `auditor_aml_holds`.
+ *
* @author Christian Grothoff
*/
#include "platform.h"
@@ -28,19 +45,28 @@
#include "taler/taler_json_lib.h"
#include "report-lib.h"
#include "taler/taler_dbevents.h"
+#include "auditor-database/delete_aml_hold.h"
#include "auditor-database/delete_early_aggregation.h"
#include "auditor-database/delete_pending_deposit.h"
#include "auditor-database/event_listen.h"
#include "auditor-database/get_auditor_progress.h"
#include "auditor-database/get_balance.h"
+#include "auditor-database/insert_aml_hold.h"
+#include "auditor-database/insert_amount_arithmetic_inconsistency.h"
#include "auditor-database/insert_auditor_progress.h"
#include "auditor-database/insert_balance.h"
#include "auditor-database/insert_early_aggregation.h"
#include "auditor-database/insert_pending_deposit.h"
+#include "auditor-database/insert_row_inconsistency.h"
#include "auditor-database/preflight.h"
#include "auditor-database/start.h"
+#include "auditor-database/update_aml_hold.h"
#include "auditor-database/update_auditor_progress.h"
#include "auditor-database/update_balance.h"
+#include "exchange-database/get_aggregation_deferral_by_wtid.h"
+#include "exchange-database/get_aggregation_transient_by_wtid.h"
+#include "exchange-database/get_open_legitimization_measure.h"
+#include "exchange-database/get_pending_aggregation.h"
#include "exchange-database/preflight.h"
#include "exchange-database/rollback.h"
struct AggregationContext;
@@ -50,6 +76,11 @@ struct ImportMissingWireContext;
#define TALER_EXCHANGEDB_WIRE_MISSING_RESULT_CLOSURE struct \
ImportMissingWireContext
#include "exchange-database/iterate_batch_deposits_missing_wire.h"
+struct HoldContext;
+#define TALER_EXCHANGEDB_AGGREGATION_WTID_RESULT_CLOSURE struct HoldContext
+#include "exchange-database/iterate_aggregation_wtids_above_serial_id.h"
+#define TALER_AUDITORDB_AML_HOLD_RESULT_CLOSURE struct HoldContext
+#include "auditor-database/iterate_aml_holds.h"
#include "exchange-database/start_read_only.h"
@@ -71,6 +102,12 @@ static TALER_ARL_DEF_PP (wire_batch_deposit_id);
static TALER_ARL_DEF_PP (wire_aggregation_id);
/**
+ * Row of `aggregation_tracking' up to which we have looked for wire transfers
+ * the exchange aggregated but did not execute.
+ */
+static TALER_ARL_DEF_PP (wire_hold_aggregation_id);
+
+/**
* Total amount which the exchange did not aggregate/transfer in time.
*/
static TALER_ARL_DEF_AB (total_amount_lag);
@@ -81,6 +118,28 @@ static TALER_ARL_DEF_AB (total_amount_lag);
static TALER_ARL_DEF_AB (total_early_aggregation);
/**
+ * Total amount the exchange aggregated but did not wire because it says a
+ * legitimization requirement against the recipient is still open. Money the
+ * exchange is withholding for legal reasons.
+ */
+static TALER_ARL_DEF_AB (total_aml_hold);
+
+/**
+ * Total amount the exchange aggregated but did not wire because what it has
+ * collected for the account so far does not cover the wire fee. Money that is
+ * waiting for the next deposit rather than for anyone's decision, and thus not
+ * an irregularity at all -- it is kept apart so that it does not inflate the
+ * two balances that are.
+ */
+static TALER_ARL_DEF_AB (total_small_aggregate);
+
+/**
+ * Total amount the exchange aggregated but did not wire and gave no reason
+ * for. Money the exchange simply has not paid out.
+ */
+static TALER_ARL_DEF_AB (total_transfer_lag);
+
+/**
* Should we run checks that only work for exchange-internal audits?
*/
static int internal_checks;
@@ -347,6 +406,604 @@ check_for_completed_transfers (void)
/**
+ * Closure for #note_held_transfer_cb() and #evaluate_hold_cb().
+ */
+struct HoldContext
+{
+ /**
+ * Wire transfers already evaluated in this round, so that the three
+ * `aggregation_tracking` rows of one transfer cost one evaluation, and so
+ * that the pass over the holds already on file does not redo them.
+ */
+ struct GNUNET_CONTAINER_MultiHashMap *seen;
+
+ /**
+ * Sum of the holds the exchange attributes to an open KYC requirement.
+ */
+ struct TALER_Amount aml_hold;
+
+ /**
+ * Sum of the holds the exchange attributes to the aggregate being too small
+ * to be worth its wire fee.
+ */
+ struct TALER_Amount small_aggregate;
+
+ /**
+ * Sum of the holds the exchange gave no reason for.
+ */
+ struct TALER_Amount transfer_lag;
+
+ /**
+ * Set to maximum row ID encountered.
+ */
+ uint64_t max_aggregation_serial;
+
+ /**
+ * Set to database errors in the callback.
+ */
+ enum GNUNET_DB_QueryStatus err;
+};
+
+
+/**
+ * Report a row inconsistency.
+ *
+ * @param[in,out] hc our state, to flag database failures in
+ * @param table name of the exchange table the bad row is in
+ * @param rowid row that is bad, 0 if the finding is not about one row
+ * @param diagnostic what is wrong with it
+ */
+static void
+report_row (struct HoldContext *hc,
+ const char *table,
+ uint64_t rowid,
+ const char *diagnostic)
+{
+ struct TALER_AUDITORDB_RowInconsistency ri = {
+ .row_id = rowid,
+ .row_table = (char *) table,
+ .diagnostic = (char *) diagnostic
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Row %llu of `%s' is bad: %s\n",
+ (unsigned long long) rowid,
+ table,
+ diagnostic);
+ qs = TALER_AUDITORDB_insert_row_inconsistency (TALER_ARL_adb,
+ &ri);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ }
+}
+
+
+/**
+ * Work out whether the exchange still owes the wire transfer @a wtid, how much
+ * it owes and what it says is keeping it from paying; then record the result
+ * and count it towards the running totals.
+ *
+ * The classification is the exchange's own, read out of `aggregation_deferrals`
+ * -- append-only, and therefore the one statement of its reasons that survives
+ * replication to an external auditor. Taking it at face value is deliberate:
+ * whether a hold is *justified* is a question for the people reading
+ * `/monitoring/aml-holds`, not for the auditor. What the auditor does check is
+ * that the claim is consistent with everything else it can see: the amount
+ * against its own recomputation, a KYC claim against the legitimization tables,
+ * and under `-i` the whole thing against the live `aggregation_transient`. A
+ * disagreement is reported rather than resolved in either side's favour.
+ *
+ * A transfer with no `aggregation_deferrals` row at all is a transfer the
+ * exchange decided on, did not make, and never explained; that is
+ * `total_transfer_lag`.
+ *
+ * @param[in,out] hc our state
+ * @param wtid wire transfer to examine
+ */
+static void
+evaluate_hold (struct HoldContext *hc,
+ const struct TALER_WireTransferIdentifierRawP *wtid)
+{
+ struct TALER_FullPaytoHashP h_payto;
+ struct TALER_FullPayto payto = { NULL };
+ struct TALER_NormalizedPaytoHashP h_normalized_payto;
+ struct TALER_Amount deposited;
+ struct TALER_Amount refunded;
+ struct TALER_Amount deposit_fee;
+ struct TALER_Amount deductions;
+ struct TALER_Amount amount;
+ struct TALER_Amount claimed;
+ struct GNUNET_TIME_Timestamp deferral_time;
+ struct GNUNET_TIME_Absolute measure_start;
+ enum TALER_EXCHANGEDB_DeferralReason reason = TALER_EXCHANGEDB_DR_NONE;
+ uint64_t claimed_measure = 0;
+ uint64_t measure = 0;
+ bool have_claim;
+ bool open_measure;
+ enum GNUNET_DB_QueryStatus qs;
+
+ qs = TALER_EXCHANGEDB_get_pending_aggregation (TALER_ARL_edb,
+ wtid,
+ &h_payto,
+ &payto,
+ &deposited,
+ &refunded,
+ &deposit_fee);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ return;
+ }
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ {
+ /* The `wire_out' row has appeared: the money left the exchange, so the
+ hold ends and stops counting towards either total. This is also the
+ only place a hold is ever cleared -- there is no separate pass over
+ `wire_out`, because a transfer that was executed simply stops being a
+ pending aggregation. */
+ qs = TALER_AUDITORDB_delete_aml_hold (TALER_ARL_adb,
+ wtid);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ }
+ return;
+ }
+
+ /* What the exchange still owes, computed the way `exchange_do_aggregate'
+ computes it, so that it is comparable with what the exchange itself
+ recorded. */
+ TALER_ARL_amount_add (&deductions,
+ &refunded,
+ &deposit_fee);
+ if (TALER_ARL_SR_INVALID_NEGATIVE ==
+ TALER_ARL_amount_subtract_neg (&amount,
+ &deposited,
+ &deductions))
+ {
+ char *diag;
+
+ GNUNET_asprintf (&diag,
+ "refunds and deposit fees exceed the deposits aggregated"
+ " into wire transfer %s",
+ TALER_B2S (wtid));
+ report_row (hc,
+ "aggregation_tracking",
+ 0,
+ diag);
+ GNUNET_free (diag);
+ GNUNET_assert (GNUNET_OK ==
+ TALER_amount_set_zero (TALER_ARL_currency,
+ &amount));
+ }
+
+ /* What the exchange says about it. Absent for a transfer it never
+ explained, and for one deferred by a version of the exchange from before
+ `aggregation_deferrals' existed. */
+ qs = TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (TALER_ARL_edb,
+ wtid,
+ &claimed,
+ &reason,
+ &claimed_measure,
+ &deferral_time);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ GNUNET_free (payto.full_payto);
+ return;
+ }
+ have_claim = (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs);
+ if (! have_claim)
+ {
+ reason = TALER_EXCHANGEDB_DR_NONE;
+ claimed_measure = 0;
+ }
+
+ /* `legitimization_measures' is reached through the account's *normalized*
+ payto hash, and `wire_targets.h_normalized_payto' is not replicated, so
+ the normalization has to happen here rather than in the query. */
+ TALER_full_payto_normalize_and_hash (payto,
+ &h_normalized_payto);
+ qs = TALER_EXCHANGEDB_get_open_legitimization_measure (
+ TALER_ARL_edb,
+ &h_normalized_payto,
+ &measure,
+ &measure_start);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ GNUNET_free (payto.full_payto);
+ return;
+ }
+ open_measure = (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs);
+ if (! open_measure)
+ measure = 0;
+ switch (reason)
+ {
+ case TALER_EXCHANGEDB_DR_NONE:
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Exchange holds %s for %s and says nothing about why\n",
+ TALER_amount2s (&amount),
+ payto.full_payto);
+ break;
+ case TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL:
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Exchange holds %s for %s, too little to cover the wire fee\n",
+ TALER_amount2s (&amount),
+ payto.full_payto);
+ break;
+ case TALER_EXCHANGEDB_DR_KYC:
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Exchange holds %s for %s awaiting KYC measure %llu\n",
+ TALER_amount2s (&amount),
+ payto.full_payto,
+ (unsigned long long) claimed_measure);
+ break;
+ }
+
+ if (have_claim &&
+ (0 != TALER_amount_cmp (&claimed,
+ &amount)) )
+ {
+ struct TALER_AUDITORDB_AmountArithmeticInconsistency aai = {
+ .problem_row_id = 0,
+ .operation = (char *) "aggregation deferral",
+ .exchange_amount = claimed,
+ .auditor_amount = amount,
+ /* claiming to hold less than it owes understates the exchange's
+ liabilities, which is the direction that favours the exchange */
+ .profitable = (0 > TALER_amount_cmp (&claimed,
+ &amount))
+ };
+
+ qs = TALER_AUDITORDB_insert_amount_arithmetic_inconsistency (
+ TALER_ARL_adb,
+ &aai);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ }
+ }
+
+ if ( (TALER_EXCHANGEDB_DR_KYC == reason) &&
+ (! open_measure) )
+ {
+ char *diag;
+
+ /* The one claim the auditor can check on its own: a legitimization
+ requirement is a row in `legitimization_measures' that no
+ `legitimization_processes' row carried through to a decision, and that
+ is replicated. Claiming KYC without one is how an exchange would dress
+ up simply keeping the money. */
+ GNUNET_asprintf (&diag,
+ "exchange withholds wire transfer %s for legitimization"
+ " measure %llu, which is not open",
+ TALER_B2S (wtid),
+ (unsigned long long) claimed_measure);
+ report_row (hc,
+ "aggregation_deferrals",
+ claimed_measure,
+ diag);
+ GNUNET_free (diag);
+ }
+
+ if (internal_checks)
+ {
+ struct TALER_Amount transient;
+ uint64_t transient_measure;
+
+ /* `aggregation_transient' is the live version of what the deferral row
+ claims: updated in place, deleted on payout, and unreplicable. On an
+ internal audit we can hold the two against each other, which is what
+ catches a `aggregation_deferrals' row that was written once and then
+ left behind by an aggregate that has moved on. */
+ qs = TALER_EXCHANGEDB_get_aggregation_transient_by_wtid (
+ TALER_ARL_edb,
+ &h_payto,
+ wtid,
+ &transient,
+ &transient_measure);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ GNUNET_free (payto.full_payto);
+ return;
+ }
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ {
+ char *diag;
+
+ /* The exchange marked the deposits as aggregated, did not transfer the
+ money, and is not tracking that it owes it either. */
+ GNUNET_asprintf (&diag,
+ "wire transfer %s was aggregated but is neither in"
+ " wire_out nor in aggregation_transient",
+ TALER_B2S (wtid));
+ report_row (hc,
+ "aggregation_transient",
+ 0,
+ diag);
+ GNUNET_free (diag);
+ }
+ else
+ {
+ if (0 !=
+ TALER_amount_cmp (&transient,
+ &amount))
+ {
+ struct TALER_AUDITORDB_AmountArithmeticInconsistency aai = {
+ .problem_row_id = 0,
+ .operation = (char *) "aggregation transient",
+ .exchange_amount = transient,
+ .auditor_amount = amount,
+ /* claiming to hold less than it owes understates the exchange's
+ liabilities, which is the direction that favours the exchange */
+ .profitable = (0 > TALER_amount_cmp (&transient,
+ &amount))
+ };
+
+ qs = TALER_AUDITORDB_insert_amount_arithmetic_inconsistency (
+ TALER_ARL_adb,
+ &aai);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ }
+ }
+ if ( (TALER_EXCHANGEDB_DR_KYC == reason) !=
+ (0 != transient_measure) )
+ {
+ char *diag;
+
+ GNUNET_asprintf (&diag,
+ "exchange is tracking wire transfer %s as %s but its"
+ " last aggregation_deferrals row says %s",
+ TALER_B2S (wtid),
+ (0 != transient_measure)
+ ? "awaiting legitimization"
+ : "not awaiting legitimization",
+ (TALER_EXCHANGEDB_DR_KYC == reason)
+ ? "it is"
+ : "it is not");
+ report_row (hc,
+ "aggregation_transient",
+ transient_measure,
+ diag);
+ GNUNET_free (diag);
+ }
+ }
+ }
+
+ qs = TALER_AUDITORDB_insert_aml_hold (TALER_ARL_adb,
+ wtid,
+ &h_payto,
+ payto,
+ &amount,
+ (uint32_t) reason,
+ claimed_measure);
+ GNUNET_free (payto.full_payto);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ return;
+ }
+ /* Insert-then-update, not "insert if new": the amount grows as further
+ deposits join the transfer, and the reason the exchange gives for holding
+ it can change long after the hold began. */
+ qs = TALER_AUDITORDB_update_aml_hold (TALER_ARL_adb,
+ wtid,
+ &amount,
+ (uint32_t) reason,
+ claimed_measure);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ hc->err = qs;
+ return;
+ }
+ {
+ struct TALER_Amount *bucket;
+
+ switch (reason)
+ {
+ case TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL:
+ bucket = &hc->small_aggregate;
+ break;
+ case TALER_EXCHANGEDB_DR_KYC:
+ bucket = &hc->aml_hold;
+ break;
+ case TALER_EXCHANGEDB_DR_NONE:
+ default:
+ bucket = &hc->transfer_lag;
+ break;
+ }
+ TALER_ARL_amount_add (bucket,
+ bucket,
+ &amount);
+ }
+}
+
+
+/**
+ * Function called on each row of the aggregation tracking table.
+ *
+ * @param hc closure
+ * @param rowid row of the entry in the aggregation tracking table
+ * @param wtid wire transfer the deposit was aggregated into
+ * @param wire_target_h_payto account the wire transfer should go to
+ * @param pending false if the exchange really did make the transfer
+ * @return #GNUNET_OK to continue to iterate
+ */
+static enum GNUNET_GenericReturnValue
+note_held_transfer_cb (
+ struct HoldContext *hc,
+ uint64_t rowid,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_FullPaytoHashP *wire_target_h_payto,
+ bool pending)
+{
+ struct GNUNET_HashCode key;
+
+ (void) wire_target_h_payto;
+ if (0 > hc->err)
+ return GNUNET_SYSERR; /* already failed */
+ GNUNET_assert (hc->max_aggregation_serial <= rowid);
+ hc->max_aggregation_serial = rowid + 1;
+ if (! pending)
+ {
+ /* Aggregated and paid, the ordinary case. It cannot have a hold on file
+ either: a wtid is only ever wired once, so the rows we are walking here
+ are the ones that created it, and a hold from an earlier run is caught
+ by the pass over the holds instead. */
+ return GNUNET_OK;
+ }
+ GNUNET_CRYPTO_hash (wtid,
+ sizeof (*wtid),
+ &key);
+ if (GNUNET_OK !=
+ GNUNET_CONTAINER_multihashmap_put (
+ hc->seen,
+ &key,
+ hc->seen,
+ GNUNET_CONTAINER_MULTIHASHMAPOPTION_UNIQUE_ONLY))
+ return GNUNET_OK; /* another row of the same transfer, already evaluated */
+ evaluate_hold (hc,
+ wtid);
+ if (0 > hc->err)
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Function called on each hold we already had on file.
+ *
+ * @param hc closure
+ * @param ah the hold
+ * @return #GNUNET_OK to continue to iterate
+ */
+static enum GNUNET_GenericReturnValue
+evaluate_hold_cb (struct HoldContext *hc,
+ const struct TALER_AUDITORDB_AmlHold *ah)
+{
+ struct GNUNET_HashCode key;
+
+ if (0 > hc->err)
+ return GNUNET_SYSERR; /* already failed */
+ GNUNET_CRYPTO_hash (&ah->wtid,
+ sizeof (ah->wtid),
+ &key);
+ if (GNUNET_YES ==
+ GNUNET_CONTAINER_multihashmap_contains (hc->seen,
+ &key))
+ return GNUNET_OK; /* fresh rows arrived for it, already re-evaluated */
+ evaluate_hold (hc,
+ &ah->wtid);
+ if (0 > hc->err)
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Checks which wire transfers the exchange aggregated but did not execute, and
+ * what it says is keeping it from making them.
+ *
+ * Unlike the two checks above, the three balances this maintains are current
+ * state and not a running tally: they are recomputed from the holds on file on
+ * every run. That is what keeps them convergent, because a hold changes in
+ * three independent ways -- it grows as deposits are added to the transfer, it
+ * is reclassified when the exchange states a different reason, and it ends when
+ * the transfer is finally made -- and only the first of those is announced by
+ * a new row that a cursor could walk.
+ *
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+check_for_held_transfers (void)
+{
+ struct HoldContext hc = {
+ .max_aggregation_serial = TALER_ARL_USE_PP (wire_hold_aggregation_id),
+ .err = GNUNET_DB_STATUS_SUCCESS_ONE_RESULT
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_assert (GNUNET_OK ==
+ TALER_amount_set_zero (TALER_ARL_currency,
+ &hc.aml_hold));
+ GNUNET_assert (GNUNET_OK ==
+ TALER_amount_set_zero (TALER_ARL_currency,
+ &hc.small_aggregate));
+ GNUNET_assert (GNUNET_OK ==
+ TALER_amount_set_zero (TALER_ARL_currency,
+ &hc.transfer_lag));
+ hc.seen = GNUNET_CONTAINER_multihashmap_create (128,
+ GNUNET_NO);
+ /* First the aggregations we have not seen before, then the holds we already
+ knew about; the map keeps the two from doing each other's work. */
+ qs = TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id (
+ TALER_ARL_edb,
+ TALER_ARL_USE_PP (wire_hold_aggregation_id),
+ ¬e_held_transfer_cb,
+ &hc);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ goto cleanup;
+ }
+ if (0 > hc.err)
+ {
+ qs = hc.err;
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ goto cleanup;
+ }
+ qs = TALER_AUDITORDB_iterate_aml_holds (TALER_ARL_adb,
+ &evaluate_hold_cb,
+ &hc);
+ if (0 > qs)
+ {
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ goto cleanup;
+ }
+ if (0 > hc.err)
+ {
+ qs = hc.err;
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ goto cleanup;
+ }
+ TALER_ARL_USE_PP (wire_hold_aggregation_id) = hc.max_aggregation_serial;
+ TALER_ARL_USE_AB (total_aml_hold) = hc.aml_hold;
+ TALER_ARL_USE_AB (total_small_aggregate) = hc.small_aggregate;
+ TALER_ARL_USE_AB (total_transfer_lag) = hc.transfer_lag;
+ /* One TALER_amount2s() per statement: it returns a single static buffer. */
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Exchange is holding %s awaiting KYC\n",
+ TALER_amount2s (&hc.aml_hold));
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Exchange is holding %s in aggregates below the wire fee\n",
+ TALER_amount2s (&hc.small_aggregate));
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Exchange is holding %s for no stated reason\n",
+ TALER_amount2s (&hc.transfer_lag));
+ qs = GNUNET_DB_STATUS_SUCCESS_ONE_RESULT;
+cleanup:
+ GNUNET_CONTAINER_multihashmap_destroy (hc.seen);
+ return qs;
+}
+
+
+/**
* Start the database transactions and begin the audit.
*
* @return transaction status
@@ -389,6 +1046,7 @@ begin_transaction (void)
TALER_ARL_adb,
TALER_ARL_GET_PP (wire_batch_deposit_id),
TALER_ARL_GET_PP (wire_aggregation_id),
+ TALER_ARL_GET_PP (wire_hold_aggregation_id),
NULL);
if (0 > qs)
goto handle_db_error;
@@ -397,6 +1055,9 @@ begin_transaction (void)
TALER_ARL_adb,
TALER_ARL_GET_AB (total_amount_lag),
TALER_ARL_GET_AB (total_early_aggregation),
+ TALER_ARL_GET_AB (total_aml_hold),
+ TALER_ARL_GET_AB (total_small_aggregate),
+ TALER_ARL_GET_AB (total_transfer_lag),
NULL);
if (0 > qs)
goto handle_db_error;
@@ -408,9 +1069,11 @@ begin_transaction (void)
else
{
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
- "Resuming transfer audit at %llu / %llu\n",
+ "Resuming transfer audit at %llu / %llu / %llu\n",
(unsigned long long) TALER_ARL_USE_PP (wire_batch_deposit_id),
- (unsigned long long) TALER_ARL_USE_PP (wire_aggregation_id));
+ (unsigned long long) TALER_ARL_USE_PP (wire_aggregation_id),
+ (unsigned long long) TALER_ARL_USE_PP (
+ wire_hold_aggregation_id));
}
qs = check_for_required_transfers ();
@@ -419,11 +1082,15 @@ begin_transaction (void)
qs = check_for_completed_transfers ();
if (0 > qs)
goto handle_db_error;
+ qs = check_for_held_transfers ();
+ if (0 > qs)
+ goto handle_db_error;
qs = TALER_AUDITORDB_update_auditor_progress (
TALER_ARL_adb,
TALER_ARL_SET_PP (wire_batch_deposit_id),
TALER_ARL_SET_PP (wire_aggregation_id),
+ TALER_ARL_SET_PP (wire_hold_aggregation_id),
NULL);
if (0 > qs)
goto handle_db_error;
@@ -431,6 +1098,7 @@ begin_transaction (void)
TALER_ARL_adb,
TALER_ARL_SET_PP (wire_batch_deposit_id),
TALER_ARL_SET_PP (wire_aggregation_id),
+ TALER_ARL_SET_PP (wire_hold_aggregation_id),
NULL);
if (0 > qs)
goto handle_db_error;
@@ -438,6 +1106,9 @@ begin_transaction (void)
TALER_ARL_adb,
TALER_ARL_SET_AB (total_amount_lag),
TALER_ARL_SET_AB (total_early_aggregation),
+ TALER_ARL_SET_AB (total_aml_hold),
+ TALER_ARL_SET_AB (total_small_aggregate),
+ TALER_ARL_SET_AB (total_transfer_lag),
NULL);
if (0 > qs)
goto handle_db_error;
@@ -445,13 +1116,18 @@ begin_transaction (void)
TALER_ARL_adb,
TALER_ARL_SET_AB (total_amount_lag),
TALER_ARL_SET_AB (total_early_aggregation),
+ TALER_ARL_SET_AB (total_aml_hold),
+ TALER_ARL_SET_AB (total_small_aggregate),
+ TALER_ARL_SET_AB (total_transfer_lag),
NULL);
if (0 > qs)
goto handle_db_error;
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
- "Concluded audit step at %llu/%llu\n",
+ "Concluded audit step at %llu/%llu/%llu\n",
(unsigned long long) TALER_ARL_USE_PP (wire_aggregation_id),
- (unsigned long long) TALER_ARL_USE_PP (wire_batch_deposit_id));
+ (unsigned long long) TALER_ARL_USE_PP (wire_batch_deposit_id),
+ (unsigned long long) TALER_ARL_USE_PP (
+ wire_hold_aggregation_id));
TALER_EXCHANGEDB_rollback (TALER_ARL_edb);
qs = TALER_AUDITORDB_commit (TALER_ARL_adb);
if (0 > qs)
diff --git a/src/auditor/test-auditor.sh b/src/auditor/test-auditor.sh
@@ -621,6 +621,28 @@ function test_0() {
"TESTKUDOS:0" \
"Wrong total bad sig loss from reserves"
+ # The aggregator ran and every transfer it decided on was executed, so
+ # nothing may be left aggregated-but-unpaid, under any of the three
+ # headings. This database has no KYC at all (see
+ # generate-auditor-basedb.sh), so a non-zero total_aml_hold would mean the
+ # exchange claimed a legal reason it cannot have had; test-kyc.sh covers
+ # the case where there genuinely is one.
+ echo -n "Test for withheld payouts... "
+ check_balance \
+ "total_aml_hold" \
+ "TESTKUDOS:0" \
+ "Auditor found a KYC hold in a database without KYC"
+ echo -n "Test for aggregates parked below the wire fee... "
+ check_balance \
+ "total_small_aggregate" \
+ "TESTKUDOS:0" \
+ "Exchange parked an aggregate as too small to pay out"
+ echo -n "Test for unexplained payout delays... "
+ check_balance \
+ "total_transfer_lag" \
+ "TESTKUDOS:0" \
+ "Exchange aggregated a payout and did not execute it"
+
echo -n "Test for aggregation wire out delta plus... "
check_balance \
"aggregation_total_wire_out_delta_plus" \
diff --git a/src/auditor/test-kyc.sh b/src/auditor/test-kyc.sh
@@ -49,7 +49,7 @@ set -eu
# Set of numbers for all the testcases.
# When adding new tests, increase the last number:
-ALL_TESTS=$(seq 0 13)
+ALL_TESTS=$(seq 0 16)
# $TESTS determines which tests we should run.
# This construction is used to make it easy to
@@ -566,6 +566,12 @@ CLEARED_WIRED="TESTKUDOS:8.02"
# Amount the exchange aggregated for the merchant that did not pass KYC
# and then parked in aggregation_transient instead of wiring it out.
WITHHELD="TESTKUDOS:8.03"
+# The account of that merchant. The IBAN libeufin derives from the account
+# name is stable across regenerations of the reference database, so this can be
+# hardcoded -- but the wire transfer identifier cannot: the aggregator draws it
+# with GNUNET_CRYPTO_random_block(), so tests 14 and 15 read it out of the
+# exchange's own tables instead of naming it.
+BLOCKED_ACCOUNT="payto://iban/DE61109451?receiver-name=Blocked44"
# Two KYCAUTH wire transfers of TESTKUDOS:0.1, one per merchant instance.
KYCAUTH_IN="TESTKUDOS:0.2"
# Everything the exchange's bank account was credited with: TESTKUDOS:56 of
@@ -764,18 +770,74 @@ function check_kyc_state() {
fi
echo "PASS"
- # ... and the auditor does not notice. See issues.txt, issue 9: the
- # withheld deposits carry aggregation_tracking rows, which
- # taler-helper-auditor-transfer.c accepts as proof that they were
- # paid, so no lag is reported for the WITHHELD amount. This
- # assertion pins down the current behaviour; when the auditor learns
- # to require a wire_out row it will fail here and should then be
- # changed to expect the withheld amount instead.
- echo -n "Checking the (unimplemented) view of withheld funds... "
+ # The exchange also said *why*, in the append-only table that replication
+ # can carry. aggregation_transient above is the same claim, but an
+ # external auditor never gets to see it, so this is the row the auditor's
+ # classification actually rests on. Reason 2 is
+ # TALER_EXCHANGEDB_DR_KYC.
+ #
+ # Restrict this to transfers that have not gone out. Both merchants were
+ # withheld on the generator's first aggregator run -- neither had passed
+ # KYC yet -- so the cleared one has a deferral of its own, made obsolete by
+ # the wire_out row that followed. Append-only means those stay on file,
+ # which is the point: the record of a hold outlives the hold.
+ echo -n "Checking that the exchange recorded why it withheld it... "
+ DEFERRED=$(psql -Aqt "$DB" \
+ -c "SELECT COUNT(*) FROM exchange.aggregation_deferrals d
+ WHERE d.deferral_reason=2
+ AND d.legitimization_requirement_serial_id != 0
+ AND NOT EXISTS (
+ SELECT 1 FROM exchange.wire_out w
+ WHERE w.wtid_raw=d.wtid_raw);")
+ if [ "$DEFERRED" != "1" ]
+ then
+ exit_fail "Expected one open KYC deferral on record, got ${DEFERRED}"
+ fi
+ echo "PASS"
+
+ # ... and the auditor sees it. The withheld deposits do carry
+ # aggregation_tracking rows, so they are not "lagging" in the sense of
+ # total_amount_lag, which counts deposits the exchange has not aggregated
+ # at all; that balance is legitimately zero here. What the exchange has
+ # not done is the wire transfer, and that is what total_aml_hold counts.
+ echo -n "Checking that nothing is waiting to be aggregated... "
check_balance \
"total_amount_lag" \
"TESTKUDOS:0" \
- "Withheld funds are now visible to the auditor: update this test and issues.txt (issue 9), expected ${WITHHELD}"
+ "Wrong total for deposits that were never aggregated"
+ echo -n "Checking that the withheld payout is booked as an AML hold... "
+ check_balance \
+ "total_aml_hold" \
+ "$WITHHELD" \
+ "Wrong total withheld pending legitimization"
+ # The blocked merchant is the *only* transfer the exchange owes, and its
+ # KYC measure is open, so nothing may land in the unexplained bucket.
+ # Were the two ever to swap, the exchange would be stalling a payout it
+ # has no legal reason to stall, which is the case this split exists for.
+ echo -n "Checking that no payout is unexplained... "
+ check_balance \
+ "total_transfer_lag" \
+ "TESTKUDOS:0" \
+ "Exchange is withholding funds it gave no reason for"
+ # Every deposit here is aggregated into a payout worth making, so the
+ # third bucket -- money parked only because the aggregate would not
+ # cover its wire fee -- must be empty. It exists so that such money
+ # does not read as either of the two above, both of which say something
+ # is wrong.
+ echo -n "Checking that nothing is parked as a small aggregate... "
+ check_balance \
+ "total_small_aggregate" \
+ "TESTKUDOS:0" \
+ "Exchange parked an aggregate as too small to pay out"
+ echo -n "Checking that the hold names the blocked account... "
+ check_report_any \
+ "aml-holds" \
+ "account" \
+ "$BLOCKED_ACCOUNT"
+ echo -n "Checking that the hold names the amount... "
+ check_report "aml-holds" "amount" "$WITHHELD"
+ echo -n "Checking that the hold is attributed to KYC... "
+ check_report "aml-holds" "deferral_reason" "KYC"
echo -n "Test for pending deposits... "
check_no_report "pending-deposits"
echo -n "Test for early aggregations... "
@@ -1371,6 +1433,196 @@ function test_13() {
}
+# Releasing the funds must retract the alert, not just stop adding to it.
+# The exchange announces the release by writing the `wire_out' row, which is
+# the only thing that distinguishes "paid" from "decided what to pay"; so
+# that is what this test injects. Nothing else changes: the deposits, the
+# aggregation_tracking rows and the open KYC measure all stay exactly as they
+# were, which is the point -- the hold must end because the money moved, and
+# for no other reason.
+function test_14() {
+
+ echo "===========14: releasing a withheld payout==========="
+ echo -n "Modifying database: "
+ # Take the transfer straight from the transient the exchange parked it in,
+ # so that the test does not have to know the randomly drawn wtid.
+ echo "INSERT INTO exchange.wire_out
+ (execution_date, wtid_raw, wire_target_h_payto,
+ exchange_account_section, amount)
+ SELECT 1785869200000000
+ ,t.wtid_raw
+ ,t.wire_target_h_payto
+ ,t.exchange_account_section
+ ,t.amount
+ FROM exchange.aggregation_transient t;" \
+ | psql -Aqt "$DB"
+ echo "DONE"
+
+ run_audit
+ check_auditor_running
+
+ echo -n "Checking that the hold was retracted... "
+ check_no_report "aml-holds"
+ echo -n "Checking that the AML total went back to zero... "
+ check_balance \
+ "total_aml_hold" \
+ "TESTKUDOS:0" \
+ "Released funds are still counted as withheld"
+ echo -n "Checking that they did not turn into a lag instead... "
+ check_balance \
+ "total_transfer_lag" \
+ "TESTKUDOS:0" \
+ "Released funds were reclassified rather than dropped"
+ echo -n "Checking that they were not parked as a small aggregate... "
+ check_balance \
+ "total_small_aggregate" \
+ "TESTKUDOS:0" \
+ "Released funds were reclassified rather than dropped"
+
+ full_reload
+ cleanup
+}
+
+
+# What the exchange *claims* decides which balance a hold lands in; whether the
+# claim holds up is a separate question, and this is the test that they are
+# separate. Here the measure the exchange named is carried through to a
+# decision while the exchange goes on withholding the money and goes on saying
+# it is withholding it for that measure.
+#
+# The money must therefore stay in `total_aml_hold' -- the auditor does not get
+# to overrule the exchange about its own reasons -- and the auditor must report
+# a row_inconsistency against `aggregation_deferrals', because the one part of
+# the claim it can check has stopped being true. Deciding what to make of that
+# is a job for the humans reading the finding.
+function test_15() {
+
+ echo "===========15: a hold whose KYC measure was satisfied==========="
+ echo -n "Modifying database: "
+ # Carry the measure that is blocking the payout through to a decision, the
+ # way the exchange would have if the merchant had filled in the form: a
+ # process referring to the measure, and an outcome for the account decided
+ # no earlier than that process started. `decision_time' is rounded up to a
+ # whole second because the exchange stores it rounded and the AML helper
+ # reads it as a timestamp.
+ echo "INSERT INTO exchange.legitimization_processes
+ (h_payto, start_time, expiration_time, provider_name,
+ legitimization_measure_serial_id, measure_index)
+ SELECT kt.h_normalized_payto
+ ,lm.start_time
+ ,0
+ ,'full_name_and_birthdate'
+ ,lm.legitimization_measure_serial_id
+ ,0
+ FROM exchange.aggregation_transient t
+ JOIN exchange.wire_targets wt
+ ON (wt.wire_target_h_payto=t.wire_target_h_payto)
+ JOIN exchange.kyc_targets kt
+ ON (kt.h_normalized_payto=wt.h_normalized_payto)
+ JOIN exchange.legitimization_measures lm
+ USING (access_token);
+ INSERT INTO exchange.legitimization_outcomes
+ (h_payto, decision_time, expiration_time, to_investigate)
+ SELECT kt.h_normalized_payto
+ ,(lm.start_time/1000000+1)*1000000
+ ,2000000000000000
+ ,FALSE
+ FROM exchange.aggregation_transient t
+ JOIN exchange.wire_targets wt
+ ON (wt.wire_target_h_payto=t.wire_target_h_payto)
+ JOIN exchange.kyc_targets kt
+ ON (kt.h_normalized_payto=wt.h_normalized_payto)
+ JOIN exchange.legitimization_measures lm
+ USING (access_token);" \
+ | psql -Aqt "$DB"
+ echo "DONE"
+
+ run_audit
+ check_auditor_running
+
+ echo -n "Checking that the exchange is still taken at its word... "
+ check_balance \
+ "total_aml_hold" \
+ "$WITHHELD" \
+ "Auditor reclassified a hold the exchange still attributes to KYC"
+ echo -n "Checking that it did not become an unexplained lag... "
+ check_balance \
+ "total_transfer_lag" \
+ "TESTKUDOS:0" \
+ "Auditor overruled the exchange's stated reason"
+ echo -n "Checking that the hold still names the claimed reason... "
+ check_report "aml-holds" "deferral_reason" "KYC"
+ echo -n "Checking that it still names the blocked account... "
+ check_report_any \
+ "aml-holds" \
+ "account" \
+ "$BLOCKED_ACCOUNT"
+ echo -n "Checking that the stale claim was reported... "
+ check_report_any \
+ "row-inconsistency" \
+ "row_table" "aggregation_deferrals"
+
+ full_reload
+ cleanup
+}
+
+
+# The other thing an exchange can lawfully be sitting on: an aggregate that
+# would not survive its own wire fee. That is not an irregularity and must not
+# read as one, so it gets a balance of its own rather than being lumped in with
+# payouts nobody has explained.
+#
+# The reference database has no such aggregate -- every payout in it is worth
+# making -- so the test restates the reason on the one deferral it does have.
+# Reason 1 is TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL, 2 is ..._DR_KYC. Note that
+# this leaves the exchange's `aggregation_transient' still naming a
+# legitimization requirement, so the -i cross-check must notice that the two
+# no longer agree.
+function test_16() {
+
+ echo "===========16: an aggregate too small to pay out==========="
+ echo -n "Modifying database: "
+ # Only the transfer that is still outstanding; the cleared merchant's
+ # deferral is on file too, but it was overtaken by its wire_out row.
+ echo "UPDATE exchange.aggregation_deferrals d
+ SET deferral_reason=1
+ ,legitimization_requirement_serial_id=0
+ WHERE NOT EXISTS (
+ SELECT 1 FROM exchange.wire_out w
+ WHERE w.wtid_raw=d.wtid_raw);" \
+ | psql -Aqt "$DB"
+ echo "DONE"
+
+ run_audit
+ check_auditor_running
+
+ echo -n "Checking that the funds are booked as a small aggregate... "
+ check_balance \
+ "total_small_aggregate" \
+ "$WITHHELD" \
+ "Aggregate below the wire fee was not booked as such"
+ echo -n "Checking that they are no longer an AML hold... "
+ check_balance \
+ "total_aml_hold" \
+ "TESTKUDOS:0" \
+ "Aggregate below the wire fee still counted as withheld for KYC"
+ echo -n "Checking that they are not an unexplained lag either... "
+ check_balance \
+ "total_transfer_lag" \
+ "TESTKUDOS:0" \
+ "Aggregate below the wire fee read as an unexplained delay"
+ echo -n "Checking that the hold names the new reason... "
+ check_report "aml-holds" "deferral_reason" "AMOUNT_TOO_SMALL"
+ echo -n "Checking that the contradiction with the transient was reported... "
+ check_report_any \
+ "row-inconsistency" \
+ "row_table" "aggregation_transient"
+
+ full_reload
+ cleanup
+}
+
+
# *************** Main test loop starts here **************
diff --git a/src/auditor/test-sync.sh b/src/auditor/test-sync.sh
@@ -117,7 +117,7 @@ function check_with_database()
-d test-sync-out.conf -t
# cs_nonce_locks excluded: no point
- for table in denominations denomination_revocations kyc_targets wire_targets reserves reserves_in reserves_close reserves_open_requests reserves_open_deposits auditors auditor_denom_sigs exchange_sign_keys signkey_revocations known_coins refresh batch_deposits coin_deposits refunds wire_out aggregation_tracking wire_fee global_fee recoup recoup_refresh extensions policy_details policy_fulfillments purse_requests purse_decision purse_merges purse_deposits account_merges history_requests close_requests wads_out wad_out_entries wads_in wad_in_entries profit_drains aml_staff purse_deletion withdraw legitimization_measures legitimization_outcomes legitimization_processes kyc_attributes aml_history kyc_events kycauths_in
+ for table in denominations denomination_revocations kyc_targets wire_targets reserves reserves_in reserves_close reserves_open_requests reserves_open_deposits auditors auditor_denom_sigs exchange_sign_keys signkey_revocations known_coins refresh batch_deposits coin_deposits refunds wire_out aggregation_tracking aggregation_deferrals wire_fee global_fee recoup recoup_refresh extensions policy_details policy_fulfillments purse_requests purse_decision purse_merges purse_deposits account_merges history_requests close_requests wads_out wad_out_entries wads_in wad_in_entries profit_drains aml_staff purse_deletion withdraw legitimization_measures legitimization_outcomes legitimization_processes kyc_attributes aml_history kyc_events kycauths_in
do
echo -n "."
CIN=$(echo "SELECT COUNT(*) FROM exchange.$table" | psql talercheck-in -Aqt)
diff --git a/src/auditordb/0003-auditor_aml_holds.sql b/src/auditordb/0003-auditor_aml_holds.sql
@@ -0,0 +1,55 @@
+--
+-- 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/>
+--
+
+CREATE TABLE IF NOT EXISTS auditor_aml_holds
+(
+ row_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE PRIMARY KEY,
+ wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32),
+ wire_target_h_payto BYTEA NOT NULL CHECK (LENGTH(wire_target_h_payto)=32),
+ account TEXT NOT NULL,
+ amount taler_amount NOT NULL,
+ deferral_reason INT4 NOT NULL,
+ legitimization_measure_serial_id BIGINT NOT NULL DEFAULT (0),
+ suppressed BOOLEAN NOT NULL DEFAULT FALSE,
+ creation_date INT8 NOT NULL
+ DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000 * 1000)::BIGINT
+);
+
+-- Deliberately not garbage collected: a row here is not a finding that ages
+-- out, it is money the exchange is still sitting on. It disappears when the
+-- wire transfer is executed, and not before.
+
+COMMENT ON TABLE auditor_aml_holds
+ IS 'Wire transfers the exchange aggregated but did not execute: it wrote the aggregation_tracking rows and marked the deposits done, but there is no wire_out row. One row per wire transfer identifier, deleted once the transfer is made.';
+
+COMMENT ON COLUMN auditor_aml_holds.row_id
+ IS 'Unique identifier of the report in the auditor database';
+COMMENT ON COLUMN auditor_aml_holds.wtid
+ IS 'Wire transfer identifier the deposits were aggregated into';
+COMMENT ON COLUMN auditor_aml_holds.wire_target_h_payto
+ IS 'Hash of the bank account the transfer should be made to';
+COMMENT ON COLUMN auditor_aml_holds.account
+ IS 'Payto URI of that bank account, for the benefit of the reader';
+COMMENT ON COLUMN auditor_aml_holds.amount
+ IS 'What the exchange still owes on this transfer: deposits minus refunds minus deposit fees, before the wire fee';
+COMMENT ON COLUMN auditor_aml_holds.deferral_reason
+ IS 'Why the exchange says it has not made the transfer, taken from its aggregation_deferrals row: 1 for an aggregate too small to cover the wire fee, 2 for an open KYC/AML requirement, 0 if the exchange gave no reason at all';
+COMMENT ON COLUMN auditor_aml_holds.legitimization_measure_serial_id
+ IS 'The measure the exchange named as blocking the payout, or 0 if it named none';
+COMMENT ON COLUMN auditor_aml_holds.suppressed
+ IS 'True if the report was suppressed by an administrator; the amount still counts towards the balances, as the funds are held either way';
+COMMENT ON COLUMN auditor_aml_holds.creation_date
+ IS 'When the auditor first saw this transfer being held';
diff --git a/src/auditordb/delete_aml_hold.c b/src/auditordb/delete_aml_hold.c
@@ -0,0 +1,44 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/auditordb/delete_aml_hold.c
+ * @brief Implementation of the delete_aml_hold function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "auditor-database/delete_aml_hold.h"
+#include "pg_helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_delete_aml_hold (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (wtid),
+ GNUNET_PQ_query_param_end
+ };
+
+ PREPARE (pg,
+ "delete_aml_hold",
+ "DELETE FROM auditor_aml_holds"
+ " WHERE wtid=$1;");
+ return GNUNET_PQ_eval_prepared_non_select (
+ pg->conn,
+ "delete_aml_hold",
+ params);
+}
diff --git a/src/auditordb/helper.c b/src/auditordb/helper.c
@@ -52,6 +52,7 @@ TALER_AUDITORDB_get_deletable_suppressable_table_name (
"auditor_wire_format_inconsistency",
"auditor_wire_out_inconsistency",
"auditor_kycauth_in_inconsistency",
+ "auditor_aml_holds",
NULL,
};
diff --git a/src/auditordb/insert_aml_hold.c b/src/auditordb/insert_aml_hold.c
@@ -0,0 +1,65 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/auditordb/insert_aml_hold.c
+ * @brief Implementation of the insert_aml_hold function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "auditor-database/insert_aml_hold.h"
+#include "pg_helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_insert_aml_hold (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_FullPaytoHashP *wire_target_h_payto,
+ const struct TALER_FullPayto account,
+ const struct TALER_Amount *amount,
+ uint32_t deferral_reason,
+ uint64_t legitimization_measure_serial_id)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (wtid),
+ GNUNET_PQ_query_param_auto_from_type (wire_target_h_payto),
+ GNUNET_PQ_query_param_string (account.full_payto),
+ TALER_PQ_query_param_amount (pg->conn,
+ amount),
+ GNUNET_PQ_query_param_uint32 (&deferral_reason),
+ GNUNET_PQ_query_param_uint64 (&legitimization_measure_serial_id),
+ GNUNET_PQ_query_param_end
+ };
+
+ /* `creation_date' is meant to say since when the funds have been held, so
+ re-seeing a hold must not move it: hence DO NOTHING rather than an
+ upsert. The caller follows up with an update for the mutable fields. */
+ PREPARE (pg,
+ "insert_aml_hold",
+ "INSERT INTO auditor_aml_holds"
+ "(wtid"
+ ",wire_target_h_payto"
+ ",account"
+ ",amount"
+ ",deferral_reason"
+ ",legitimization_measure_serial_id"
+ ") VALUES ($1,$2,$3,$4,$5,$6)"
+ " ON CONFLICT (wtid) DO NOTHING;");
+ return GNUNET_PQ_eval_prepared_non_select (
+ pg->conn,
+ "insert_aml_hold",
+ params);
+}
diff --git a/src/auditordb/iterate_aml_holds.c b/src/auditordb/iterate_aml_holds.c
@@ -0,0 +1,155 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/auditordb/iterate_aml_holds.c
+ * @brief Implementation of the iterate_aml_holds function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "auditor-database/iterate_aml_holds.h"
+#include "pg_helper.h"
+
+
+/**
+ * Closure for #aml_holds_cb().
+ */
+struct AmlHoldsContext
+{
+ /**
+ * Function to call for each hold.
+ */
+ TALER_AUDITORDB_AmlHoldCallback cb;
+
+ /**
+ * Closure for @e cb.
+ */
+ void *cb_cls;
+
+ /**
+ * Plugin context.
+ */
+ struct TALER_AUDITORDB_PostgresContext *pg;
+
+ /**
+ * Query status to return.
+ */
+ enum GNUNET_DB_QueryStatus qs;
+};
+
+
+/**
+ * Helper function for #TALER_AUDITORDB_iterate_aml_holds().
+ * To be called with the results of a SELECT statement
+ * that has returned @a num_results results.
+ *
+ * @param cls closure of type `struct AmlHoldsContext *`
+ * @param result the postgres result
+ * @param num_results the number of results in @a result
+ */
+static void
+aml_holds_cb (void *cls,
+ PGresult *result,
+ unsigned int num_results)
+{
+ struct AmlHoldsContext *ahc = cls;
+ struct TALER_AUDITORDB_PostgresContext *pg = ahc->pg;
+
+ for (unsigned int i = 0; i < num_results; i++)
+ {
+ struct TALER_AUDITORDB_AmlHold ah;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("row_id",
+ &ah.row_id),
+ GNUNET_PQ_result_spec_auto_from_type ("wtid",
+ &ah.wtid),
+ GNUNET_PQ_result_spec_auto_from_type ("wire_target_h_payto",
+ &ah.wire_target_h_payto),
+ GNUNET_PQ_result_spec_string ("account",
+ &ah.account.full_payto),
+ TALER_PQ_RESULT_SPEC_AMOUNT ("amount",
+ &ah.amount),
+ GNUNET_PQ_result_spec_uint32 ("deferral_reason",
+ &ah.deferral_reason),
+ GNUNET_PQ_result_spec_uint64 ("legitimization_measure_serial_id",
+ &ah.legitimization_measure_serial_id),
+ GNUNET_PQ_result_spec_absolute_time ("creation_date",
+ &ah.creation_date),
+ GNUNET_PQ_result_spec_bool ("suppressed",
+ &ah.suppressed),
+ GNUNET_PQ_result_spec_end
+ };
+ enum GNUNET_GenericReturnValue rval;
+
+ if (GNUNET_OK !=
+ GNUNET_PQ_extract_result (result,
+ rs,
+ i))
+ {
+ GNUNET_break (0);
+ ahc->qs = GNUNET_DB_STATUS_HARD_ERROR;
+ return;
+ }
+ ahc->qs = i + 1;
+ rval = ahc->cb (ahc->cb_cls,
+ &ah);
+ GNUNET_PQ_cleanup_result (rs);
+ if (GNUNET_OK != rval)
+ break;
+ }
+}
+
+
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_iterate_aml_holds (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ TALER_AUDITORDB_AmlHoldCallback cb,
+ TALER_AUDITORDB_AML_HOLD_RESULT_CLOSURE *cb_cls)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_end
+ };
+ struct AmlHoldsContext ahc = {
+ .cb = cb,
+ .cb_cls = cb_cls,
+ .pg = pg
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ PREPARE (pg,
+ "iterate_aml_holds",
+ "SELECT"
+ " row_id"
+ ",wtid"
+ ",wire_target_h_payto"
+ ",account"
+ ",amount"
+ ",deferral_reason"
+ ",legitimization_measure_serial_id"
+ ",creation_date"
+ ",suppressed"
+ " FROM auditor_aml_holds"
+ " ORDER BY row_id ASC;");
+ qs = GNUNET_PQ_eval_prepared_multi_select (
+ pg->conn,
+ "iterate_aml_holds",
+ params,
+ &aml_holds_cb,
+ &ahc);
+ if (qs > 0)
+ return ahc.qs;
+ GNUNET_break (GNUNET_DB_STATUS_HARD_ERROR != qs);
+ return qs;
+}
diff --git a/src/auditordb/iterate_auditor_aml_holds.c b/src/auditordb/iterate_auditor_aml_holds.c
@@ -0,0 +1,184 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/auditordb/iterate_auditor_aml_holds.c
+ * @brief Implementation of the iterate_auditor_aml_holds function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "auditor-database/iterate_auditor_aml_holds.h"
+#include "pg_helper.h"
+
+
+/**
+ * Closure for #auditor_aml_holds_cb().
+ */
+struct AuditorAmlHoldsContext
+{
+ /**
+ * Function to call for each hold.
+ */
+ TALER_AUDITORDB_AuditorAmlHoldCallback cb;
+
+ /**
+ * Closure for @e cb.
+ */
+ void *cb_cls;
+
+ /**
+ * Plugin context.
+ */
+ struct TALER_AUDITORDB_PostgresContext *pg;
+
+ /**
+ * Query status to return.
+ */
+ enum GNUNET_DB_QueryStatus qs;
+};
+
+
+/**
+ * Helper function for #TALER_AUDITORDB_iterate_auditor_aml_holds().
+ * To be called with the results of a SELECT statement
+ * that has returned @a num_results results.
+ *
+ * @param cls closure of type `struct AuditorAmlHoldsContext *`
+ * @param result the postgres result
+ * @param num_results the number of results in @a result
+ */
+static void
+auditor_aml_holds_cb (void *cls,
+ PGresult *result,
+ unsigned int num_results)
+{
+ struct AuditorAmlHoldsContext *ahc = cls;
+ struct TALER_AUDITORDB_PostgresContext *pg = ahc->pg;
+
+ for (unsigned int i = 0; i < num_results; i++)
+ {
+ struct TALER_AUDITORDB_AmlHold ah;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("row_id",
+ &ah.row_id),
+ GNUNET_PQ_result_spec_auto_from_type ("wtid",
+ &ah.wtid),
+ GNUNET_PQ_result_spec_auto_from_type ("wire_target_h_payto",
+ &ah.wire_target_h_payto),
+ GNUNET_PQ_result_spec_string ("account",
+ &ah.account.full_payto),
+ TALER_PQ_RESULT_SPEC_AMOUNT ("amount",
+ &ah.amount),
+ GNUNET_PQ_result_spec_uint32 ("deferral_reason",
+ &ah.deferral_reason),
+ GNUNET_PQ_result_spec_uint64 ("legitimization_measure_serial_id",
+ &ah.legitimization_measure_serial_id),
+ GNUNET_PQ_result_spec_absolute_time ("creation_date",
+ &ah.creation_date),
+ GNUNET_PQ_result_spec_bool ("suppressed",
+ &ah.suppressed),
+ GNUNET_PQ_result_spec_end
+ };
+ enum GNUNET_GenericReturnValue rval;
+
+ if (GNUNET_OK !=
+ GNUNET_PQ_extract_result (result,
+ rs,
+ i))
+ {
+ GNUNET_break (0);
+ ahc->qs = GNUNET_DB_STATUS_HARD_ERROR;
+ return;
+ }
+ ahc->qs = i + 1;
+ rval = ahc->cb (ahc->cb_cls,
+ &ah);
+ GNUNET_PQ_cleanup_result (rs);
+ if (GNUNET_OK != rval)
+ break;
+ }
+}
+
+
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_iterate_auditor_aml_holds (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ int64_t limit,
+ uint64_t offset,
+ bool return_suppressed,
+ TALER_AUDITORDB_AuditorAmlHoldCallback cb,
+ TALER_AUDITORDB_AUDITOR_AML_HOLD_RESULT_CLOSURE *cb_cls)
+{
+ uint64_t plimit = (uint64_t) ((limit < 0) ? -limit : limit);
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&offset),
+ GNUNET_PQ_query_param_bool (return_suppressed),
+ GNUNET_PQ_query_param_uint64 (&plimit),
+ GNUNET_PQ_query_param_end
+ };
+ struct AuditorAmlHoldsContext ahc = {
+ .cb = cb,
+ .cb_cls = cb_cls,
+ .pg = pg
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ PREPARE (pg,
+ "iterate_auditor_aml_holds_desc",
+ "SELECT"
+ " row_id"
+ ",wtid"
+ ",wire_target_h_payto"
+ ",account"
+ ",amount"
+ ",deferral_reason"
+ ",legitimization_measure_serial_id"
+ ",creation_date"
+ ",suppressed"
+ " FROM auditor_aml_holds"
+ " WHERE (row_id < $1)"
+ " AND ($2 OR NOT suppressed)"
+ " ORDER BY row_id DESC"
+ " LIMIT $3");
+ PREPARE (pg,
+ "iterate_auditor_aml_holds_asc",
+ "SELECT"
+ " row_id"
+ ",wtid"
+ ",wire_target_h_payto"
+ ",account"
+ ",amount"
+ ",deferral_reason"
+ ",legitimization_measure_serial_id"
+ ",creation_date"
+ ",suppressed"
+ " FROM auditor_aml_holds"
+ " WHERE (row_id > $1)"
+ " AND ($2 OR NOT suppressed)"
+ " ORDER BY row_id ASC"
+ " LIMIT $3");
+ qs = GNUNET_PQ_eval_prepared_multi_select (
+ pg->conn,
+ (limit > 0)
+ ? "iterate_auditor_aml_holds_asc"
+ : "iterate_auditor_aml_holds_desc",
+ params,
+ &auditor_aml_holds_cb,
+ &ahc);
+ if (qs > 0)
+ return ahc.qs;
+ GNUNET_break (GNUNET_DB_STATUS_HARD_ERROR != qs);
+ return qs;
+}
diff --git a/src/auditordb/meson.build b/src/auditordb/meson.build
@@ -52,6 +52,7 @@ auditor_0002_sql = [
auditor_0003_sql = [
'0003-preamble.sql',
+ '0003-auditor_aml_holds.sql',
'0003-auditor_aml_staff.sql',
'0003-auditor_kycauth_in_inconsistency.sql',
'commit.sql',
@@ -83,6 +84,7 @@ libtalerauditordb = library(
'talerauditordb',
[
'create_tables.c',
+ 'delete_aml_hold.c',
'delete_denomination_balance.c',
'delete_auditor_closure_lag.c',
'delete_early_aggregation.c',
@@ -97,8 +99,10 @@ libtalerauditordb = library(
'event_listen.c',
'event_notify.c',
'gc.c',
+ 'iterate_aml_holds.c',
'iterate_aml_staff.c',
'iterate_amount_arithmetic_inconsistencies.c',
+ 'iterate_auditor_aml_holds.c',
'iterate_auditor_closure_lags.c',
'get_auditor_progress.c',
'iterate_bad_sig_losses.c',
@@ -133,6 +137,7 @@ libtalerauditordb = library(
'iterate_wire_format_inconsistencies.c',
'iterate_wire_out_inconsistencies.c',
'helper.c',
+ 'insert_aml_hold.c',
'insert_aml_staff.c',
'insert_amount_arithmetic_inconsistency.c',
'insert_auditor_closure_lags.c',
@@ -176,6 +181,7 @@ libtalerauditordb = library(
'get_reserve_in_inconsistency.c',
'start.c',
'update_auditor_progress.c',
+ 'update_aml_hold.c',
'update_balance.c',
'update_denomination_balance.c',
'update_to_suppressed.c',
diff --git a/src/auditordb/restart.sql b/src/auditordb/restart.sql
@@ -31,6 +31,7 @@ SET search_path TO auditor;
-- Unlike the other SQL files, it SHOULD be updated to reflect the
-- latest requirements for dropping tables.
+DELETE FROM auditor_aml_holds;
DELETE FROM auditor_aml_staff;
DELETE FROM auditor_amount_arithmetic_inconsistency;
DELETE FROM auditor_bad_sig_losses;
diff --git a/src/auditordb/update_aml_hold.c b/src/auditordb/update_aml_hold.c
@@ -0,0 +1,54 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/auditordb/update_aml_hold.c
+ * @brief Implementation of the update_aml_hold function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "auditor-database/update_aml_hold.h"
+#include "pg_helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_update_aml_hold (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_Amount *amount,
+ uint32_t deferral_reason,
+ uint64_t legitimization_measure_serial_id)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ TALER_PQ_query_param_amount (pg->conn,
+ amount),
+ GNUNET_PQ_query_param_uint32 (&deferral_reason),
+ GNUNET_PQ_query_param_uint64 (&legitimization_measure_serial_id),
+ GNUNET_PQ_query_param_auto_from_type (wtid),
+ GNUNET_PQ_query_param_end
+ };
+
+ PREPARE (pg,
+ "update_aml_hold",
+ "UPDATE auditor_aml_holds"
+ " SET amount=$1"
+ " ,deferral_reason=$2"
+ " ,legitimization_measure_serial_id=$3"
+ " WHERE wtid=$4;");
+ return GNUNET_PQ_eval_prepared_non_select (
+ pg->conn,
+ "update_aml_hold",
+ params);
+}
diff --git a/src/exchange/taler-exchange-aggregator.c b/src/exchange/taler-exchange-aggregator.c
@@ -47,6 +47,7 @@
#include "exchange-database/insert_prewire.h"
#include "exchange-database/insert_wire_out.h"
#include "exchange-database/delete_aggregation_transient.h"
+#include "exchange-database/insert_aggregation_deferral.h"
#include "exchange-database/update_aggregation_transient.h"
#include "exchange-database/event_notify.h"
#include "exchange-database/insert_prewire.h"
@@ -933,6 +934,29 @@ evaluate_rules (
return;
}
+ /* Say so on the record as well: the transient above is invisible to the
+ auditor (no serial ID, updated in place, deleted on payout), so without
+ this the auditor sees money aggregated and not sent and has to guess
+ whether we are withholding it lawfully. */
+ qs = TALER_EXCHANGEDB_insert_aggregation_deferral (
+ pg,
+ &au->wtid,
+ &au->h_full_payto,
+ &au->total_amount,
+ TALER_EXCHANGEDB_DR_KYC,
+ au->requirement_row,
+ au->execution_time);
+ if (qs < 0)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Failed to persist deferral reason in DB!\n");
+ GNUNET_break (GNUNET_DB_STATUS_SOFT_ERROR == qs);
+ if (GNUNET_DB_STATUS_HARD_ERROR == qs)
+ global_ret = EXIT_FAILURE;
+ cleanup_and_next (au);
+ return;
+ }
+
{
struct TALER_EXCHANGEDB_CoinDepositEventP rep = {
.header.size = htons (sizeof (rep)),
@@ -994,6 +1018,38 @@ commit_to_transient (struct AggregationUnit *au)
fail_aggregation (au);
return;
}
+ if (! au->legi_check)
+ {
+ /* Nothing is pending on our side: we have decided that the aggregate is
+ not worth its wire fee yet, and that is the whole reason the money is
+ still here. Put that on the record for the auditor.
+
+ When @e legi_check is set we are instead merely parking the aggregate
+ so that the KYC rules can be evaluated in a transaction of their own,
+ and we do not yet know whether we will be paying out or withholding --
+ evaluate_rules() records the deferral in the latter case. */
+ qs = TALER_EXCHANGEDB_insert_aggregation_deferral (
+ pg,
+ &au->wtid,
+ &au->h_full_payto,
+ &au->total_amount,
+ TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL,
+ 0,
+ au->execution_time);
+ if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Serialization issue, trying again later!\n");
+ rollback_aggregation (au);
+ return;
+ }
+ if (GNUNET_DB_STATUS_HARD_ERROR == qs)
+ {
+ GNUNET_break (0);
+ fail_aggregation (au);
+ return;
+ }
+ }
au->have_transient = true;
/* commit */
commit_aggregation (au);
diff --git a/src/exchangedb/get_aggregation_deferral_by_wtid.c b/src/exchangedb/get_aggregation_deferral_by_wtid.c
@@ -0,0 +1,73 @@
+/*
+ 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/>
+ */
+/**
+ * @file exchangedb/get_aggregation_deferral_by_wtid.c
+ * @brief Implementation of the get_aggregation_deferral_by_wtid function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_error_codes.h"
+#include "taler/taler_pq_lib.h"
+#include "exchange-database/get_aggregation_deferral_by_wtid.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ struct TALER_Amount *amount,
+ enum TALER_EXCHANGEDB_DeferralReason *reason,
+ uint64_t *kyc_requirement_row,
+ struct GNUNET_TIME_Timestamp *deferral_time)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (wtid),
+ GNUNET_PQ_query_param_end
+ };
+ uint32_t reason32;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ TALER_PQ_RESULT_SPEC_AMOUNT ("amount",
+ amount),
+ GNUNET_PQ_result_spec_uint32 ("deferral_reason",
+ &reason32),
+ GNUNET_PQ_result_spec_uint64 ("legitimization_requirement_serial_id",
+ kyc_requirement_row),
+ GNUNET_PQ_result_spec_timestamp ("deferral_time",
+ deferral_time),
+ GNUNET_PQ_result_spec_end
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ PREPARE (pg,
+ "get_aggregation_deferral_by_wtid",
+ "SELECT"
+ " amount"
+ ",deferral_reason"
+ ",legitimization_requirement_serial_id"
+ ",deferral_time"
+ " FROM aggregation_deferrals"
+ " WHERE wtid_raw=$1"
+ " ORDER BY aggregation_deferral_serial_id DESC"
+ " LIMIT 1;");
+ qs = GNUNET_PQ_eval_prepared_singleton_select (
+ pg->conn,
+ "get_aggregation_deferral_by_wtid",
+ params,
+ rs);
+ if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs)
+ *reason = (enum TALER_EXCHANGEDB_DeferralReason) reason32;
+ return qs;
+}
diff --git a/src/exchangedb/get_aggregation_transient_by_wtid.c b/src/exchangedb/get_aggregation_transient_by_wtid.c
@@ -0,0 +1,61 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/exchangedb/get_aggregation_transient_by_wtid.c
+ * @brief Implementation of the get_aggregation_transient_by_wtid function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "helper.h"
+#include "exchange-database/get_aggregation_transient_by_wtid.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_get_aggregation_transient_by_wtid (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_FullPaytoHashP *wire_target_h_payto,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ struct TALER_Amount *amount,
+ uint64_t *legitimization_measure_serial_id)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (wire_target_h_payto),
+ GNUNET_PQ_query_param_auto_from_type (wtid),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ TALER_PQ_RESULT_SPEC_AMOUNT ("amount",
+ amount),
+ GNUNET_PQ_result_spec_uint64 ("legitimization_requirement_serial_id",
+ legitimization_measure_serial_id),
+ GNUNET_PQ_result_spec_end
+ };
+
+ /* (wire_target_h_payto, wtid_raw) is the table's unique key. */
+ PREPARE (pg,
+ "get_aggregation_transient_by_wtid",
+ "SELECT"
+ " amount"
+ " ,legitimization_requirement_serial_id"
+ " FROM aggregation_transient"
+ " WHERE wire_target_h_payto=$1"
+ " AND wtid_raw=$2;");
+ return GNUNET_PQ_eval_prepared_singleton_select (
+ pg->conn,
+ "get_aggregation_transient_by_wtid",
+ params,
+ rs);
+}
diff --git a/src/exchangedb/get_open_legitimization_measure.c b/src/exchangedb/get_open_legitimization_measure.c
@@ -0,0 +1,82 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/exchangedb/get_open_legitimization_measure.c
+ * @brief Implementation of the get_open_legitimization_measure function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "helper.h"
+#include "exchange-database/get_open_legitimization_measure.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_get_open_legitimization_measure (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ uint64_t *legitimization_measure_serial_id,
+ struct GNUNET_TIME_Absolute *start_time)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (h_payto),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("legitimization_measure_serial_id",
+ legitimization_measure_serial_id),
+ GNUNET_PQ_result_spec_absolute_time ("start_time",
+ start_time),
+ GNUNET_PQ_result_spec_end
+ };
+
+ /* `legitimization_measures' is keyed by the account's access token, which
+ lives in `kyc_targets', while `legitimization_processes' and
+ `legitimization_outcomes' are keyed by the normalized payto hash --
+ hence the join.
+
+ The second of slack on `start_time' is the same one as in
+ iterate_legitimization_outcomes_above_serial_id.c: `decision_time' is a
+ rounded timestamp while `start_time' is not, so an honest process can
+ start up to a second "after" the decision it fed. Subtracting from
+ start_time rather than adding to decision_time keeps a decision_time of
+ "forever" from overflowing. */
+ PREPARE (pg,
+ "get_open_legitimization_measure",
+ "SELECT"
+ " lm.legitimization_measure_serial_id"
+ ",lm.start_time"
+ " FROM legitimization_measures lm"
+ " JOIN kyc_targets kt"
+ " USING (access_token)"
+ " WHERE kt.h_normalized_payto=$1"
+ " AND NOT EXISTS ("
+ " SELECT 1"
+ " FROM legitimization_processes lp"
+ " WHERE lp.legitimization_measure_serial_id"
+ " =lm.legitimization_measure_serial_id"
+ " AND EXISTS ("
+ " SELECT 1"
+ " FROM legitimization_outcomes lo"
+ " WHERE lo.h_payto=kt.h_normalized_payto"
+ " AND lo.decision_time>=lp.start_time-1000000))"
+ " ORDER BY lm.legitimization_measure_serial_id ASC"
+ " LIMIT 1;");
+ return GNUNET_PQ_eval_prepared_singleton_select (
+ pg->conn,
+ "get_open_legitimization_measure",
+ params,
+ rs);
+}
diff --git a/src/exchangedb/get_pending_aggregation.c b/src/exchangedb/get_pending_aggregation.c
@@ -0,0 +1,145 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/exchangedb/get_pending_aggregation.c
+ * @brief Implementation of the get_pending_aggregation function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "helper.h"
+#include "exchange-database/get_pending_aggregation.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_get_pending_aggregation (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ struct TALER_FullPaytoHashP *wire_target_h_payto,
+ struct TALER_FullPayto *payto_uri,
+ struct TALER_Amount *total_deposited,
+ struct TALER_Amount *total_refunded,
+ struct TALER_Amount *total_deposit_fee)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (wtid),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_auto_from_type ("wire_target_h_payto",
+ wire_target_h_payto),
+ GNUNET_PQ_result_spec_string ("payto_uri",
+ &payto_uri->full_payto),
+ TALER_PQ_RESULT_SPEC_AMOUNT ("total_deposited",
+ total_deposited),
+ TALER_PQ_RESULT_SPEC_AMOUNT ("total_refunded",
+ total_refunded),
+ TALER_PQ_RESULT_SPEC_AMOUNT ("total_deposit_fee",
+ total_deposit_fee),
+ GNUNET_PQ_result_spec_end
+ };
+
+ /* This mirrors the arithmetic of `exchange_do_aggregate' (see
+ do_aggregate.c) so that the result can be compared with what the exchange
+ itself computed. The one subtlety worth preserving is
+ `fully_refunded_coins': the exchange does not keep the deposit fee of a
+ coin whose deposit was refunded in full, so neither may we.
+
+ The `NOT EXISTS' on wire_out is what makes this a *pending* aggregation;
+ once the transfer is executed the query returns no rows and the caller
+ drops the hold. */
+ PREPARE (pg,
+ "get_pending_aggregation",
+ "WITH bd AS ("
+ " SELECT bdep.batch_deposit_serial_id"
+ " ,bdep.wire_target_h_payto"
+ " FROM aggregation_tracking atr"
+ " JOIN batch_deposits bdep"
+ " USING (batch_deposit_serial_id)"
+ " WHERE atr.wtid_raw=$1)"
+ " ,cd AS ("
+ " SELECT cdep.coin_pub"
+ " ,cdep.batch_deposit_serial_id"
+ " ,cdep.amount_with_fee AS amount"
+ " FROM coin_deposits cdep"
+ " WHERE cdep.batch_deposit_serial_id IN"
+ " (SELECT batch_deposit_serial_id FROM bd))"
+ " ,ref AS ("
+ " SELECT r.amount_with_fee AS refund"
+ " ,r.coin_pub"
+ " ,r.batch_deposit_serial_id"
+ " FROM refunds r"
+ " WHERE r.batch_deposit_serial_id IN"
+ " (SELECT batch_deposit_serial_id FROM bd))"
+ " ,ref_by_coin AS ("
+ " SELECT SUM((ref.refund).val) AS sum_val"
+ " ,SUM((ref.refund).frac) AS sum_frac"
+ " ,coin_pub"
+ " ,batch_deposit_serial_id"
+ " FROM ref"
+ " GROUP BY coin_pub, batch_deposit_serial_id)"
+ " ,fully_refunded_coins AS ("
+ " SELECT cd.coin_pub"
+ " ,cd.batch_deposit_serial_id"
+ " FROM ref_by_coin n"
+ " JOIN cd"
+ " ON (n.coin_pub = cd.coin_pub"
+ " AND n.batch_deposit_serial_id = cd.batch_deposit_serial_id"
+ " AND n.sum_val + n.sum_frac / 100000000 = (cd.amount).val"
+ " AND n.sum_frac % 100000000 = (cd.amount).frac))"
+ " ,fees AS ("
+ " SELECT denom.fee_deposit AS fee"
+ " FROM cd"
+ " JOIN known_coins kc"
+ " USING (coin_pub)"
+ " JOIN denominations denom"
+ " USING (denominations_serial)"
+ " WHERE (cd.coin_pub, cd.batch_deposit_serial_id) NOT IN"
+ " (SELECT coin_pub, batch_deposit_serial_id"
+ " FROM fully_refunded_coins))"
+ "SELECT"
+ " (SELECT wire_target_h_payto FROM bd LIMIT 1)"
+ " AS wire_target_h_payto"
+ " ,(SELECT wt.payto_uri"
+ " FROM wire_targets wt"
+ " WHERE wt.wire_target_h_payto="
+ " (SELECT wire_target_h_payto FROM bd LIMIT 1))"
+ " AS payto_uri"
+ " ,ROW(COALESCE(SUM((cd.amount).val),0)"
+ " + COALESCE(SUM((cd.amount).frac),0) / 100000000"
+ " ,COALESCE(SUM((cd.amount).frac),0) % 100000000)::taler_amount"
+ " AS total_deposited"
+ " ,(SELECT ROW(COALESCE(SUM((ref.refund).val),0)"
+ " + COALESCE(SUM((ref.refund).frac),0) / 100000000"
+ " ,COALESCE(SUM((ref.refund).frac),0)"
+ " % 100000000)::taler_amount"
+ " FROM ref)"
+ " AS total_refunded"
+ " ,(SELECT ROW(COALESCE(SUM((fees.fee).val),0)"
+ " + COALESCE(SUM((fees.fee).frac),0) / 100000000"
+ " ,COALESCE(SUM((fees.fee).frac),0)"
+ " % 100000000)::taler_amount"
+ " FROM fees)"
+ " AS total_deposit_fee"
+ " FROM cd"
+ " WHERE NOT EXISTS ("
+ " SELECT 1 FROM wire_out wo WHERE wo.wtid_raw=$1)"
+ " HAVING COUNT(*) > 0;");
+ return GNUNET_PQ_eval_prepared_singleton_select (
+ pg->conn,
+ "get_pending_aggregation",
+ params,
+ rs);
+}
diff --git a/src/exchangedb/get_serial_by_table.c b/src/exchangedb/get_serial_by_table.c
@@ -219,6 +219,14 @@ TALER_EXCHANGEDB_get_serial_by_table (struct
" ORDER BY aggregation_serial_id DESC"
" LIMIT 1;");
break;
+ case TALER_EXCHANGEDB_RT_AGGREGATION_DEFERRALS:
+ XPREPARE ("select_serial_by_table_aggregation_deferrals",
+ "SELECT"
+ " aggregation_deferral_serial_id AS serial"
+ " FROM aggregation_deferrals"
+ " ORDER BY aggregation_deferral_serial_id DESC"
+ " LIMIT 1;");
+ break;
case TALER_EXCHANGEDB_RT_WIRE_FEE:
XPREPARE ("select_serial_by_table_wire_fee",
"SELECT"
diff --git a/src/exchangedb/insert_aggregation_deferral.c b/src/exchangedb/insert_aggregation_deferral.c
@@ -0,0 +1,79 @@
+/*
+ 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/>
+ */
+/**
+ * @file exchangedb/insert_aggregation_deferral.c
+ * @brief Implementation of the insert_aggregation_deferral function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_error_codes.h"
+#include "taler/taler_pq_lib.h"
+#include "exchange-database/insert_aggregation_deferral.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_insert_aggregation_deferral (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_FullPaytoHashP *h_payto,
+ const struct TALER_Amount *amount,
+ enum TALER_EXCHANGEDB_DeferralReason reason,
+ uint64_t kyc_requirement_row,
+ struct GNUNET_TIME_Timestamp deferral_time)
+{
+ uint32_t reason32 = (uint32_t) reason;
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (wtid),
+ GNUNET_PQ_query_param_auto_from_type (h_payto),
+ TALER_PQ_query_param_amount (pg->conn,
+ amount),
+ GNUNET_PQ_query_param_uint32 (&reason32),
+ GNUNET_PQ_query_param_uint64 (&kyc_requirement_row),
+ GNUNET_PQ_query_param_timestamp (&deferral_time),
+ GNUNET_PQ_query_param_end
+ };
+
+ /* The deposit we point at is picked here rather than passed in because it
+ is not a fact about the deferral at all: it exists only so that
+ exchange_do_main_gc() deleting the aggregate's deposits takes the
+ deferral with it. Hence the *last* deposit to reach its wire deadline --
+ that is the one whose collection means the whole aggregate is being aged
+ out. If the aggregation_tracking rows are not there yet the SELECT is
+ empty and nothing is inserted, which is why the caller must run after
+ TALER_EXCHANGEDB_do_aggregate(). */
+ PREPARE (pg,
+ "insert_aggregation_deferral",
+ "INSERT INTO aggregation_deferrals"
+ " (batch_deposit_serial_id"
+ " ,wtid_raw"
+ " ,wire_target_h_payto"
+ " ,amount"
+ " ,deferral_reason"
+ " ,legitimization_requirement_serial_id"
+ " ,deferral_time)"
+ " SELECT"
+ " atr.batch_deposit_serial_id"
+ " ,$1, $2, $3, $4, $5, $6"
+ " FROM aggregation_tracking atr"
+ " JOIN batch_deposits bdep"
+ " USING (batch_deposit_serial_id)"
+ " WHERE atr.wtid_raw=$1"
+ " ORDER BY bdep.wire_deadline DESC"
+ " LIMIT 1;");
+ return GNUNET_PQ_eval_prepared_non_select (pg->conn,
+ "insert_aggregation_deferral",
+ params);
+}
diff --git a/src/exchangedb/insert_records_by_table.c b/src/exchangedb/insert_records_by_table.c
@@ -1138,6 +1138,56 @@ irbt_cb_table_aggregation_tracking (struct TALER_EXCHANGEDB_PostgresContext *pg,
/**
+ * Function called with aggregation_deferrals records to insert into table.
+ *
+ * @param pg plugin context
+ * @param td record to insert
+ */
+static enum GNUNET_DB_QueryStatus
+irbt_cb_table_aggregation_deferrals (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_EXCHANGEDB_TableData *td)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&td->serial),
+ GNUNET_PQ_query_param_uint64 (
+ &td->details.aggregation_deferrals.batch_deposit_serial_id),
+ GNUNET_PQ_query_param_auto_from_type (
+ &td->details.aggregation_deferrals.wtid_raw),
+ GNUNET_PQ_query_param_auto_from_type (
+ &td->details.aggregation_deferrals.wire_target_h_payto),
+ TALER_PQ_query_param_amount (
+ pg->conn,
+ &td->details.aggregation_deferrals.amount),
+ GNUNET_PQ_query_param_uint32 (
+ &td->details.aggregation_deferrals.deferral_reason),
+ GNUNET_PQ_query_param_uint64 (
+ &td->details.aggregation_deferrals.legitimization_requirement_serial_id),
+ GNUNET_PQ_query_param_timestamp (
+ &td->details.aggregation_deferrals.deferral_time),
+ GNUNET_PQ_query_param_end
+ };
+
+ PREPARE (pg,
+ "insert_records_by_table_into_table_aggregation_deferrals",
+ "INSERT INTO aggregation_deferrals"
+ "(aggregation_deferral_serial_id"
+ ",batch_deposit_serial_id"
+ ",wtid_raw"
+ ",wire_target_h_payto"
+ ",amount"
+ ",deferral_reason"
+ ",legitimization_requirement_serial_id"
+ ",deferral_time"
+ ") VALUES "
+ "($1, $2, $3, $4, $5, $6, $7, $8);");
+ return GNUNET_PQ_eval_prepared_non_select (pg->conn,
+ "insert_records_by_table_into_table_aggregation_deferrals",
+ params);
+}
+
+
+/**
* Function called with wire_fee records to insert into table.
*
* @param pg plugin context
@@ -2247,6 +2297,9 @@ TALER_EXCHANGEDB_insert_records_by_table (
case TALER_EXCHANGEDB_RT_AGGREGATION_TRACKING:
rh = &irbt_cb_table_aggregation_tracking;
break;
+ case TALER_EXCHANGEDB_RT_AGGREGATION_DEFERRALS:
+ rh = &irbt_cb_table_aggregation_deferrals;
+ break;
case TALER_EXCHANGEDB_RT_WIRE_FEE:
rh = &irbt_cb_table_wire_fee;
break;
diff --git a/src/exchangedb/iterate_aggregation_wtids_above_serial_id.c b/src/exchangedb/iterate_aggregation_wtids_above_serial_id.c
@@ -0,0 +1,155 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/exchangedb/iterate_aggregation_wtids_above_serial_id.c
+ * @brief Implementation of the iterate_aggregation_wtids_above_serial_id function for Postgres
+ * @author Christian Grothoff
+ */
+#include "taler/taler_pq_lib.h"
+#include "helper.h"
+#include "exchange-database/iterate_aggregation_wtids_above_serial_id.h"
+
+
+/**
+ * Closure for #aggregation_wtid_cb().
+ */
+struct AggregationWtidContext
+{
+ /**
+ * Function to call for each aggregation.
+ */
+ TALER_EXCHANGEDB_AggregationWtidCallback cb;
+
+ /**
+ * Closure for @e cb.
+ */
+ void *cb_cls;
+
+ /**
+ * Query status to return.
+ */
+ enum GNUNET_DB_QueryStatus qs;
+};
+
+
+/**
+ * Helper function for
+ * #TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id().
+ * To be called with the results of a SELECT statement
+ * that has returned @a num_results results.
+ *
+ * @param cls closure of type `struct AggregationWtidContext *`
+ * @param result the postgres result
+ * @param num_results the number of results in @a result
+ */
+static void
+aggregation_wtid_cb (void *cls,
+ PGresult *result,
+ unsigned int num_results)
+{
+ struct AggregationWtidContext *awc = cls;
+
+ for (unsigned int i = 0; i < num_results; i++)
+ {
+ uint64_t rowid;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_FullPaytoHashP wire_target_h_payto;
+ bool pending;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("aggregation_serial_id",
+ &rowid),
+ GNUNET_PQ_result_spec_auto_from_type ("wtid_raw",
+ &wtid),
+ GNUNET_PQ_result_spec_auto_from_type ("wire_target_h_payto",
+ &wire_target_h_payto),
+ GNUNET_PQ_result_spec_bool ("pending",
+ &pending),
+ GNUNET_PQ_result_spec_end
+ };
+ enum GNUNET_GenericReturnValue rval;
+
+ if (GNUNET_OK !=
+ GNUNET_PQ_extract_result (result,
+ rs,
+ i))
+ {
+ GNUNET_break (0);
+ awc->qs = GNUNET_DB_STATUS_HARD_ERROR;
+ return;
+ }
+ awc->qs = i + 1;
+ rval = awc->cb (awc->cb_cls,
+ rowid,
+ &wtid,
+ &wire_target_h_payto,
+ pending);
+ GNUNET_PQ_cleanup_result (rs);
+ if (GNUNET_OK != rval)
+ break;
+ }
+}
+
+
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint64_t serial_id,
+ TALER_EXCHANGEDB_AggregationWtidCallback cb,
+ TALER_EXCHANGEDB_AGGREGATION_WTID_RESULT_CLOSURE *cb_cls)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_uint64 (&serial_id),
+ GNUNET_PQ_query_param_end
+ };
+ struct AggregationWtidContext awc = {
+ .cb = cb,
+ .cb_cls = cb_cls
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ /* Rows that already have their `wire_out' are returned as well, flagged as
+ not pending: the caller needs every serial ID to be able to advance its
+ cursor past them, but knowing here that the transfer happened saves it
+ from asking #TALER_EXCHANGEDB_get_pending_aggregation() -- a full
+ recomputation of the transfer -- once per wire transfer the exchange ever
+ made, which is what the first run after an upgrade would otherwise do. */
+ PREPARE (pg,
+ "iterate_aggregation_wtids_above_serial_id",
+ "SELECT"
+ " atr.aggregation_serial_id"
+ ",atr.wtid_raw"
+ ",bdep.wire_target_h_payto"
+ ",NOT EXISTS ("
+ " SELECT 1"
+ " FROM wire_out wo"
+ " WHERE wo.wtid_raw=atr.wtid_raw"
+ " ) AS pending"
+ " FROM aggregation_tracking atr"
+ " JOIN batch_deposits bdep"
+ " USING (batch_deposit_serial_id)"
+ " WHERE atr.aggregation_serial_id>=$1"
+ " ORDER BY atr.aggregation_serial_id ASC;");
+ qs = GNUNET_PQ_eval_prepared_multi_select (
+ pg->conn,
+ "iterate_aggregation_wtids_above_serial_id",
+ params,
+ &aggregation_wtid_cb,
+ &awc);
+ if (qs > 0)
+ return awc.qs;
+ GNUNET_break (GNUNET_DB_STATUS_HARD_ERROR != qs);
+ return qs;
+}
diff --git a/src/exchangedb/iterate_records_by_table.c b/src/exchangedb/iterate_records_by_table.c
@@ -1251,6 +1251,70 @@ lrbt_cb_table_wire_out (void *cls,
/**
+ * Function called with aggregation_deferrals table entries.
+ *
+ * @param cls closure
+ * @param result the postgres result
+ * @param num_results the number of results in @a result
+ */
+static void
+lrbt_cb_table_aggregation_deferrals (void *cls,
+ PGresult *result,
+ unsigned int num_results)
+{
+ struct LookupRecordsByTableContext *ctx = cls;
+ struct TALER_EXCHANGEDB_PostgresContext *pg = ctx->pg;
+ struct TALER_EXCHANGEDB_TableData td = {
+ .table = TALER_EXCHANGEDB_RT_AGGREGATION_DEFERRALS
+ };
+
+ for (unsigned int i = 0; i<num_results; i++)
+ {
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 (
+ "serial",
+ &td.serial),
+ GNUNET_PQ_result_spec_uint64 (
+ "batch_deposit_serial_id",
+ &td.details.aggregation_deferrals.batch_deposit_serial_id),
+ GNUNET_PQ_result_spec_auto_from_type (
+ "wtid_raw",
+ &td.details.aggregation_deferrals.wtid_raw),
+ GNUNET_PQ_result_spec_auto_from_type (
+ "wire_target_h_payto",
+ &td.details.aggregation_deferrals.wire_target_h_payto),
+ TALER_PQ_RESULT_SPEC_AMOUNT (
+ "amount",
+ &td.details.aggregation_deferrals.amount),
+ GNUNET_PQ_result_spec_uint32 (
+ "deferral_reason",
+ &td.details.aggregation_deferrals.deferral_reason),
+ GNUNET_PQ_result_spec_uint64 (
+ "legitimization_requirement_serial_id",
+ &td.details.aggregation_deferrals.legitimization_requirement_serial_id),
+ GNUNET_PQ_result_spec_timestamp (
+ "deferral_time",
+ &td.details.aggregation_deferrals.deferral_time),
+ GNUNET_PQ_result_spec_end
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_PQ_extract_result (result,
+ rs,
+ i))
+ {
+ GNUNET_break (0);
+ ctx->error = true;
+ return;
+ }
+ ctx->cb (ctx->cb_cls,
+ &td);
+ GNUNET_PQ_cleanup_result (rs);
+ }
+}
+
+
+/**
* Function called with aggregation_tracking table entries.
*
* @param cls closure
@@ -3222,6 +3286,22 @@ TALER_EXCHANGEDB_iterate_records_by_table (
" ORDER BY aggregation_serial_id ASC;");
rh = &lrbt_cb_table_aggregation_tracking;
break;
+ case TALER_EXCHANGEDB_RT_AGGREGATION_DEFERRALS:
+ XPREPARE ("select_above_serial_by_table_aggregation_deferrals",
+ "SELECT"
+ " aggregation_deferral_serial_id AS serial"
+ ",batch_deposit_serial_id"
+ ",wtid_raw"
+ ",wire_target_h_payto"
+ ",amount"
+ ",deferral_reason"
+ ",legitimization_requirement_serial_id"
+ ",deferral_time"
+ " FROM aggregation_deferrals"
+ " WHERE aggregation_deferral_serial_id > $1"
+ " ORDER BY aggregation_deferral_serial_id ASC;");
+ rh = &lrbt_cb_table_aggregation_deferrals;
+ break;
case TALER_EXCHANGEDB_RT_WIRE_FEE:
XPREPARE ("select_above_serial_by_table_wire_fee",
"SELECT"
diff --git a/src/exchangedb/meson.build b/src/exchangedb/meson.build
@@ -61,6 +61,7 @@ libtalerexchangedb = library(
'complete_shard.c',
'compute_shard.c',
'get_count_known_coins.c',
+ 'insert_aggregation_deferral.c',
'insert_aggregation_transient.c',
'create_tables.c',
'delete_aggregation_transient.c',
@@ -191,8 +192,13 @@ libtalerexchangedb = library(
'rollback.c',
'iterate_account_merges_above_serial_id.c',
'iterate_aggregation_amounts_for_kyc_check.c',
+ 'iterate_aggregation_wtids_above_serial_id.c',
'iterate_aggregations_above_serial_id.c',
+ 'get_aggregation_deferral_by_wtid.c',
'get_aggregation_transient.c',
+ 'get_aggregation_transient_by_wtid.c',
+ 'get_open_legitimization_measure.c',
+ 'get_pending_aggregation.c',
'iterate_all_kyc_attributes.c',
'iterate_all_purse_decisions_above_serial_id.c',
'iterate_all_purse_deletions_above_serial_id.c',
diff --git a/src/exchangedb/sql-schema/0012-aggregation_deferrals.sql b/src/exchangedb/sql-schema/0012-aggregation_deferrals.sql
@@ -0,0 +1,183 @@
+--
+-- 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/>
+--
+
+-- The exchange's own record of why it aggregated a payout and then did not
+-- make it.
+--
+-- aggregation_transient already holds that reason, but it cannot be the
+-- answer for an auditor: it has no serial ID, it is UPDATEd in place and it
+-- is DELETEd once the transfer goes out, so replication -- which walks each
+-- table by its serial ID and only ever appends -- can neither carry it nor
+-- notice that it changed. An external auditor could therefore see that a
+-- payout was decided and not executed, but never why, and had to guess from
+-- the KYC tables whether the exchange was withholding the money lawfully or
+-- simply sitting on it.
+--
+-- This table is that claim, append-only: one row per occasion on which the
+-- aggregator decided not to pay out, stating what it was holding and why.
+-- The exchange is not taken at its word -- the auditor still recomputes the
+-- amount and checks a KYC claim against the legitimization tables -- but a
+-- disagreement is now a finding against a statement the exchange signed up
+-- to, rather than the auditor's own inference against nothing.
+--
+-- Rows are not garbage collected on their own. batch_deposit_serial_id
+-- names the last deposit of the aggregate to reach its wire deadline, so
+-- exchange_do_main_gc() removing that deposit takes the deferral with it:
+-- the claim lives exactly as long as the deposits it is a claim about.
+
+CREATE FUNCTION create_table_aggregation_deferrals(
+ IN partition_suffix TEXT DEFAULT NULL
+)
+RETURNS VOID
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ table_name TEXT DEFAULT 'aggregation_deferrals';
+BEGIN
+ PERFORM create_partitioned_table(
+ 'CREATE TABLE %I'
+ '(aggregation_deferral_serial_id BIGINT GENERATED BY DEFAULT AS IDENTITY'
+ ',batch_deposit_serial_id INT8 NOT NULL'
+ ',wtid_raw BYTEA NOT NULL CHECK (LENGTH(wtid_raw)=32)'
+ ',wire_target_h_payto BYTEA NOT NULL CHECK (LENGTH(wire_target_h_payto)=32)'
+ ',amount taler_amount NOT NULL'
+ ',deferral_reason INT4 NOT NULL'
+ ',legitimization_requirement_serial_id INT8 NOT NULL DEFAULT(0)'
+ ',deferral_time INT8 NOT NULL'
+ ') %s ;'
+ ,table_name
+ ,'PARTITION BY HASH (batch_deposit_serial_id)'
+ ,partition_suffix
+ );
+ PERFORM comment_partitioned_table(
+ 'reasons the aggregator gave for not executing a wire transfer it had already decided on; append-only, and thus replicated to the auditor (unlike aggregation_transient)'
+ ,table_name
+ ,partition_suffix
+ );
+ PERFORM comment_partitioned_column(
+ 'the deposit of the aggregate that is the last to reach its wire deadline; only present to tie the lifetime of this row to that of the deposits it talks about, as garbage collecting the deposit cascades to here'
+ ,'batch_deposit_serial_id'
+ ,table_name
+ ,partition_suffix
+ );
+ PERFORM comment_partitioned_column(
+ 'identifier of the wire transfer that was not executed'
+ ,'wtid_raw'
+ ,table_name
+ ,partition_suffix
+ );
+ PERFORM comment_partitioned_column(
+ 'unsalted hash of the (full) payto URI of the account that was to receive the funds'
+ ,'wire_target_h_payto'
+ ,table_name
+ ,partition_suffix
+ );
+ PERFORM comment_partitioned_column(
+ 'sum of the aggregated deposits (minus refunds and deposit fees) that was withheld, matching aggregation_transient.amount at the time'
+ ,'amount'
+ ,table_name
+ ,partition_suffix
+ );
+ PERFORM comment_partitioned_column(
+ 'why the transfer was not made: 1 for an aggregate too small to cover the wire fee, 2 for an open KYC/AML requirement against the recipient'
+ ,'deferral_reason'
+ ,table_name
+ ,partition_suffix
+ );
+ PERFORM comment_partitioned_column(
+ 'legitimization measure that has to be satisfied before the transfer can be made, or 0 if the transfer was not deferred for KYC reasons'
+ ,'legitimization_requirement_serial_id'
+ ,table_name
+ ,partition_suffix
+ );
+ PERFORM comment_partitioned_column(
+ 'when the aggregator made this decision'
+ ,'deferral_time'
+ ,table_name
+ ,partition_suffix
+ );
+END
+$$;
+
+
+CREATE FUNCTION constrain_table_aggregation_deferrals(
+ IN partition_suffix TEXT
+)
+RETURNS VOID
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ table_name TEXT DEFAULT 'aggregation_deferrals';
+BEGIN
+ table_name = concat_ws('_', table_name, partition_suffix);
+ EXECUTE FORMAT (
+ 'ALTER TABLE ' || table_name ||
+ ' ADD CONSTRAINT ' || table_name || '_aggregation_deferral_serial_id_key'
+ ' UNIQUE (aggregation_deferral_serial_id)'
+ );
+ EXECUTE FORMAT (
+ 'CREATE INDEX ' || table_name || '_by_wtid_raw_index '
+ 'ON ' || table_name || ' '
+ '(wtid_raw);'
+ );
+ EXECUTE FORMAT (
+ 'COMMENT ON INDEX ' || table_name || '_by_wtid_raw_index '
+ 'IS ' || quote_literal('for get_aggregation_deferral_by_wtid') || ';'
+ );
+END
+$$;
+
+
+CREATE FUNCTION foreign_table_aggregation_deferrals()
+RETURNS VOID
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ table_name TEXT DEFAULT 'aggregation_deferrals';
+BEGIN
+ EXECUTE FORMAT (
+ 'ALTER TABLE ' || table_name ||
+ ' ADD CONSTRAINT ' || table_name || '_foreign_deposit'
+ ' FOREIGN KEY (batch_deposit_serial_id)'
+ ' REFERENCES batch_deposits (batch_deposit_serial_id)'
+ ' ON DELETE CASCADE'
+ );
+END
+$$;
+
+
+INSERT INTO exchange_tables
+ (name
+ ,version
+ ,action
+ ,partitioned
+ ,by_range)
+ VALUES
+ ('aggregation_deferrals'
+ ,'exchange-0012'
+ ,'create'
+ ,TRUE
+ ,FALSE),
+ ('aggregation_deferrals'
+ ,'exchange-0012'
+ ,'constrain'
+ ,TRUE
+ ,FALSE),
+ ('aggregation_deferrals'
+ ,'exchange-0012'
+ ,'foreign'
+ ,TRUE
+ ,FALSE);
diff --git a/src/exchangedb/sql-schema/0012-preamble.sql b/src/exchangedb/sql-schema/0012-preamble.sql
@@ -0,0 +1,20 @@
+--
+-- 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('exchange-0012', NULL, NULL);
+SET search_path TO exchange;
diff --git a/src/exchangedb/sql-schema/meson.build b/src/exchangedb/sql-schema/meson.build
@@ -161,6 +161,12 @@ exchange_0010_sql = [
exchange_0011_sql = ['0011-preamble.sql', '0011-aml_staff.sql', 'commit.sql']
+exchange_0012_sql = [
+ '0012-preamble.sql',
+ '0012-aggregation_deferrals.sql',
+ 'commit.sql',
+]
+
generated_sql = [
['auditor-triggers-0001.sql', ['auditor-triggers-0001.sql']],
['auditor-triggers-0002.sql', ['auditor-triggers-0002.sql']],
@@ -179,6 +185,7 @@ generated_sql = [
['exchange-0009.sql', exchange_0009_sql],
['exchange-0010.sql', exchange_0010_sql],
['exchange-0011.sql', exchange_0011_sql],
+ ['exchange-0012.sql', exchange_0012_sql],
['tops-0001.sql', ['tops-0001.sql']],
]
diff --git a/src/include/auditor-database/delete_aml_hold.h b/src/include/auditor-database/delete_aml_hold.h
@@ -0,0 +1,40 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/auditor-database/delete_aml_hold.h
+ * @brief implementation of the delete_aml_hold function
+ * @author Christian Grothoff
+ */
+#ifndef AUDITOR_DATABASE_DELETE_AML_HOLD_H
+#define AUDITOR_DATABASE_DELETE_AML_HOLD_H
+
+#include "taler/taler_util.h"
+#include "auditordb_lib.h"
+
+
+/**
+ * Drop the hold on @a wtid, because the exchange executed the transfer.
+ *
+ * @param pg the database context
+ * @param wtid the wire transfer that is no longer being held
+ * @return query result status
+ */
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_delete_aml_hold (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid);
+
+#endif
diff --git a/src/include/auditor-database/insert_aml_hold.h b/src/include/auditor-database/insert_aml_hold.h
@@ -0,0 +1,53 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/auditor-database/insert_aml_hold.h
+ * @brief implementation of the insert_aml_hold function
+ * @author Christian Grothoff
+ */
+#ifndef AUDITOR_DATABASE_INSERT_AML_HOLD_H
+#define AUDITOR_DATABASE_INSERT_AML_HOLD_H
+
+#include "taler/taler_util.h"
+#include "auditordb_lib.h"
+
+
+/**
+ * Record that the exchange aggregated deposits into @a wtid but has not
+ * executed the transfer. Does nothing if the hold is already on file; use
+ * #TALER_AUDITORDB_update_aml_hold() to refresh it.
+ *
+ * @param pg the database context
+ * @param wtid the wire transfer that was not executed
+ * @param wire_target_h_payto account the transfer should go to
+ * @param account payto URI of that account
+ * @param amount what the exchange still owes on the transfer
+ * @param deferral_reason why the exchange says it has not paid, a
+ * `enum TALER_EXCHANGEDB_DeferralReason` value
+ * @param legitimization_measure_serial_id that measure, or 0
+ * @return query result status
+ */
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_insert_aml_hold (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_FullPaytoHashP *wire_target_h_payto,
+ const struct TALER_FullPayto account,
+ const struct TALER_Amount *amount,
+ uint32_t deferral_reason,
+ uint64_t legitimization_measure_serial_id);
+
+#endif
diff --git a/src/include/auditor-database/iterate_aml_holds.h b/src/include/auditor-database/iterate_aml_holds.h
@@ -0,0 +1,67 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/auditor-database/iterate_aml_holds.h
+ * @brief implementation of the iterate_aml_holds function
+ * @author Christian Grothoff
+ */
+#ifndef AUDITOR_DATABASE_ITERATE_AML_HOLDS_H
+#define AUDITOR_DATABASE_ITERATE_AML_HOLDS_H
+
+#include "taler/taler_util.h"
+#include "auditordb_lib.h"
+
+
+#ifndef TALER_AUDITORDB_AML_HOLD_RESULT_CLOSURE
+/**
+ * Type of the closure for #TALER_AUDITORDB_AmlHoldCallback.
+ */
+#define TALER_AUDITORDB_AML_HOLD_RESULT_CLOSURE void
+#endif
+
+/**
+ * Function called on each hold currently on file.
+ *
+ * @param cls closure
+ * @param ah the hold
+ * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop iterating
+ */
+typedef enum GNUNET_GenericReturnValue
+(*TALER_AUDITORDB_AmlHoldCallback)(
+ TALER_AUDITORDB_AML_HOLD_RESULT_CLOSURE *cls,
+ const struct TALER_AUDITORDB_AmlHold *ah);
+
+
+/**
+ * Iterate over every wire transfer the exchange is currently holding.
+ *
+ * Unlike #TALER_AUDITORDB_iterate_auditor_aml_holds() this returns suppressed
+ * rows as well and takes no limit: it is meant for the audit helper, which has
+ * to re-examine each hold on every run, and for which a suppressed row is
+ * still money that is being held.
+ *
+ * @param pg the database context
+ * @param cb function to call with results
+ * @param cb_cls closure for @a cb
+ * @return query result status
+ */
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_iterate_aml_holds (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ TALER_AUDITORDB_AmlHoldCallback cb,
+ TALER_AUDITORDB_AML_HOLD_RESULT_CLOSURE *cb_cls);
+
+#endif
diff --git a/src/include/auditor-database/iterate_auditor_aml_holds.h b/src/include/auditor-database/iterate_auditor_aml_holds.h
@@ -0,0 +1,69 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/auditor-database/iterate_auditor_aml_holds.h
+ * @brief implementation of the iterate_auditor_aml_holds function
+ * @author Christian Grothoff
+ */
+#ifndef AUDITOR_DATABASE_ITERATE_AUDITOR_AML_HOLDS_H
+#define AUDITOR_DATABASE_ITERATE_AUDITOR_AML_HOLDS_H
+
+#include "taler/taler_util.h"
+#include "taler/taler_json_lib.h"
+#include "auditordb_lib.h"
+
+
+#ifndef TALER_AUDITORDB_AUDITOR_AML_HOLD_RESULT_CLOSURE
+/**
+ * Type of the closure for #TALER_AUDITORDB_AuditorAmlHoldCallback.
+ */
+#define TALER_AUDITORDB_AUDITOR_AML_HOLD_RESULT_CLOSURE void
+#endif
+
+/**
+ * Function called with holds stored in the auditor's database.
+ *
+ * @param cls closure
+ * @param ah the structure itself
+ * @return #GNUNET_OK to continue to iterate, #GNUNET_SYSERR to stop iterating
+ */
+typedef enum GNUNET_GenericReturnValue
+(*TALER_AUDITORDB_AuditorAmlHoldCallback)(
+ TALER_AUDITORDB_AUDITOR_AML_HOLD_RESULT_CLOSURE *cls,
+ const struct TALER_AUDITORDB_AmlHold *ah);
+
+
+/**
+ * Get information about held wire transfers from the database.
+ *
+ * @param pg the database context
+ * @param limit number of records to return, negative for descending
+ * @param offset table row to start from, exclusive, direction determined by @a limit
+ * @param return_suppressed should suppressed rows be returned anyway?
+ * @param cb function to call with results
+ * @param cb_cls closure for @a cb
+ * @return query result status
+ */
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_iterate_auditor_aml_holds (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ int64_t limit,
+ uint64_t offset,
+ bool return_suppressed,
+ TALER_AUDITORDB_AuditorAmlHoldCallback cb,
+ TALER_AUDITORDB_AUDITOR_AML_HOLD_RESULT_CLOSURE *cb_cls);
+
+#endif
diff --git a/src/include/auditor-database/update_aml_hold.h b/src/include/auditor-database/update_aml_hold.h
@@ -0,0 +1,49 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/auditor-database/update_aml_hold.h
+ * @brief implementation of the update_aml_hold function
+ * @author Christian Grothoff
+ */
+#ifndef AUDITOR_DATABASE_UPDATE_AML_HOLD_H
+#define AUDITOR_DATABASE_UPDATE_AML_HOLD_H
+
+#include "taler/taler_util.h"
+#include "auditordb_lib.h"
+
+
+/**
+ * Refresh what we know about a hold: the amount grows as further deposits are
+ * aggregated into the same wire transfer, and the KYC measure that explains it
+ * can appear (or be decided) long after the hold started.
+ *
+ * @param pg the database context
+ * @param wtid the wire transfer that was not executed
+ * @param amount what the exchange still owes on the transfer
+ * @param deferral_reason why the exchange says it has not paid, a
+ * `enum TALER_EXCHANGEDB_DeferralReason` value
+ * @param legitimization_measure_serial_id that measure, or 0
+ * @return query result status
+ */
+enum GNUNET_DB_QueryStatus
+TALER_AUDITORDB_update_aml_hold (
+ struct TALER_AUDITORDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_Amount *amount,
+ uint32_t deferral_reason,
+ uint64_t legitimization_measure_serial_id);
+
+#endif
diff --git a/src/include/auditordb_lib.h b/src/include/auditordb_lib.h
@@ -646,6 +646,11 @@ enum TALER_AUDITORDB_DeletableSuppressableTables
TALER_AUDITORDB_KYCAUTH_IN_INCONSISTENCY,
/**
+ * For auditor_aml_holds table.
+ */
+ TALER_AUDITORDB_AML_HOLDS,
+
+ /**
* Terminal.
*/
TALER_AUDITORDB_DELETABLESUPPRESSABLE_TABLES_MAX
@@ -653,6 +658,56 @@ enum TALER_AUDITORDB_DeletableSuppressableTables
/**
+ * Information about a wire transfer the exchange aggregated but did not
+ * execute.
+ */
+struct TALER_AUDITORDB_AmlHold
+{
+ uint64_t row_id;
+
+ /**
+ * Wire transfer the deposits were aggregated into.
+ */
+ struct TALER_WireTransferIdentifierRawP wtid;
+
+ /**
+ * Account the transfer should be made to.
+ */
+ struct TALER_FullPaytoHashP wire_target_h_payto;
+
+ /**
+ * Payto URI of @e wire_target_h_payto.
+ */
+ struct TALER_FullPayto account;
+
+ /**
+ * What the exchange still owes on this transfer.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Why the exchange says it has not made the transfer, an
+ * `enum TALER_EXCHANGEDB_DeferralReason` value taken from its
+ * `aggregation_deferrals` row. Zero if the exchange gave no reason at all,
+ * which is the case that says it is simply not paying.
+ */
+ uint32_t deferral_reason;
+
+ /**
+ * Measure the exchange named as blocking the payout, or 0 if it named none.
+ */
+ uint64_t legitimization_measure_serial_id;
+
+ /**
+ * When did the auditor first see this transfer being held?
+ */
+ struct GNUNET_TIME_Absolute creation_date;
+
+ bool suppressed;
+};
+
+
+/**
* Information about an arithmetic inconsistency
*/
struct TALER_AUDITORDB_AmountArithmeticInconsistency
diff --git a/src/include/exchange-database/get_aggregation_deferral_by_wtid.h b/src/include/exchange-database/get_aggregation_deferral_by_wtid.h
@@ -0,0 +1,55 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/exchange-database/get_aggregation_deferral_by_wtid.h
+ * @brief implementation of the get_aggregation_deferral_by_wtid function for Postgres
+ * @author Christian Grothoff
+ */
+#ifndef EXCHANGE_DATABASE_GET_AGGREGATION_DEFERRAL_BY_WTID_H
+#define EXCHANGE_DATABASE_GET_AGGREGATION_DEFERRAL_BY_WTID_H
+
+#include "taler/taler_util.h"
+#include "exchangedb_lib.h"
+
+/**
+ * Obtain the exchange's most recent statement of why it has not executed the
+ * wire transfer @a wtid. Used by the auditor, which reads this out of its
+ * replica; `aggregation_deferrals` is append-only, so the row with the
+ * greatest serial ID is the claim in force.
+ *
+ * Returns #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the exchange never said
+ * anything about @a wtid, which for a transfer that is still pending means
+ * the exchange is simply not paying.
+ *
+ * @param pg the database context
+ * @param wtid wire transfer to look up
+ * @param[out] amount set to the amount the exchange said it was withholding
+ * @param[out] reason set to the reason the exchange gave
+ * @param[out] kyc_requirement_row set to the legitimization measure the
+ * exchange named, or 0 if it named none
+ * @param[out] deferral_time set to when the exchange made the decision
+ * @return transaction status
+ */
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ struct TALER_Amount *amount,
+ enum TALER_EXCHANGEDB_DeferralReason *reason,
+ uint64_t *kyc_requirement_row,
+ struct GNUNET_TIME_Timestamp *deferral_time);
+
+#endif
diff --git a/src/include/exchange-database/get_aggregation_transient_by_wtid.h b/src/include/exchange-database/get_aggregation_transient_by_wtid.h
@@ -0,0 +1,56 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/exchange-database/get_aggregation_transient_by_wtid.h
+ * @brief implementation of the get_aggregation_transient_by_wtid function
+ * @author Christian Grothoff
+ */
+#ifndef EXCHANGE_DATABASE_GET_AGGREGATION_TRANSIENT_BY_WTID_H
+#define EXCHANGE_DATABASE_GET_AGGREGATION_TRANSIENT_BY_WTID_H
+
+#include "taler/taler_util.h"
+#include "exchangedb_lib.h"
+
+
+/**
+ * Obtain what the exchange itself says about a wire transfer it has aggregated
+ * but not executed: how much it is holding, and whether it is holding it for
+ * legitimization reasons.
+ *
+ * `aggregation_transient` is updated in place and deleted on payout, so it has
+ * no serial ID and `taler-auditor-sync` cannot replicate it. This is
+ * therefore only usable for an exchange-internal audit (`-i`), where it serves
+ * to check the auditor's own inference about the account against the reason
+ * the exchange actually recorded.
+ *
+ * @param pg the database context
+ * @param wire_target_h_payto account the transfer should go to
+ * @param wtid wire transfer identifier to look up
+ * @param[out] amount set to the amount the exchange says it is holding
+ * @param[out] legitimization_measure_serial_id set to the measure the exchange
+ * is waiting for, or 0 if the exchange claims no legitimization reason
+ * @return #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the exchange has no
+ * transient aggregation for @a wtid
+ */
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_get_aggregation_transient_by_wtid (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_FullPaytoHashP *wire_target_h_payto,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ struct TALER_Amount *amount,
+ uint64_t *legitimization_measure_serial_id);
+
+#endif
diff --git a/src/include/exchange-database/get_open_legitimization_measure.h b/src/include/exchange-database/get_open_legitimization_measure.h
@@ -0,0 +1,60 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/exchange-database/get_open_legitimization_measure.h
+ * @brief implementation of the get_open_legitimization_measure function
+ * @author Christian Grothoff
+ */
+#ifndef EXCHANGE_DATABASE_GET_OPEN_LEGITIMIZATION_MEASURE_H
+#define EXCHANGE_DATABASE_GET_OPEN_LEGITIMIZATION_MEASURE_H
+
+#include "taler/taler_util.h"
+#include "exchangedb_lib.h"
+
+
+/**
+ * Find a KYC measure that the exchange imposed on @a h_payto and that has not
+ * yet produced a decision. Such a measure is the exchange's own explanation
+ * for why it is not paying the account out.
+ *
+ * A measure counts as satisfied once some `legitimization_processes` row
+ * refers to it *and* the account has a `legitimization_outcomes` row that was
+ * decided no earlier than that process started. Both halves are needed: a
+ * process that was started and abandoned decides nothing, and an outcome that
+ * predates the process belongs to an earlier measure.
+ *
+ * This deliberately uses only columns that `taler-auditor-sync` replicates, so
+ * that an external auditor reaches the same verdict as an internal one. In
+ * particular it cannot look at `legitimization_measures.is_finished` or
+ * `legitimization_processes.finished`, neither of which is replicated.
+ *
+ * @param pg the database context
+ * @param h_payto normalized hash of the payto URI of the account
+ * @param[out] legitimization_measure_serial_id set to the lowest such measure
+ * @param[out] start_time set to when that measure was imposed; the exchange
+ * writes this column at full precision, so it is not a rounded
+ * timestamp
+ * @return #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if every measure on the
+ * account was decided, including if there never was one
+ */
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_get_open_legitimization_measure (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ uint64_t *legitimization_measure_serial_id,
+ struct GNUNET_TIME_Absolute *start_time);
+
+#endif
diff --git a/src/include/exchange-database/get_pending_aggregation.h b/src/include/exchange-database/get_pending_aggregation.h
@@ -0,0 +1,61 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/exchange-database/get_pending_aggregation.h
+ * @brief implementation of the get_pending_aggregation function
+ * @author Christian Grothoff
+ */
+#ifndef EXCHANGE_DATABASE_GET_PENDING_AGGREGATION_H
+#define EXCHANGE_DATABASE_GET_PENDING_AGGREGATION_H
+
+#include "taler/taler_util.h"
+#include "exchangedb_lib.h"
+
+
+/**
+ * Obtain the deposits that were aggregated into the wire transfer @a wtid but
+ * that the exchange has not (yet) executed, that is: for which there is no
+ * `wire_out` row.
+ *
+ * The three totals are returned separately rather than netted out because the
+ * exchange nets them out itself in `exchange_do_aggregate`, and the caller has
+ * to be able to reproduce that arithmetic exactly: the amount the exchange
+ * still owes is @a total_deposited - @a total_refunded - @a total_deposit_fee,
+ * where the deposit fee is *not* charged for coins that were refunded in full.
+ *
+ * @param pg the database context
+ * @param wtid wire transfer identifier to look up
+ * @param[out] wire_target_h_payto set to the account the transfer should go to
+ * @param[out] payto_uri set to the URI of that account, to be freed by the
+ * caller; needed because the normalized form of the account is not
+ * replicated to an external auditor
+ * @param[out] total_deposited set to the sum of the deposited amounts
+ * @param[out] total_refunded set to the sum of the refunded amounts
+ * @param[out] total_deposit_fee set to the sum of the deposit fees charged
+ * @return #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the transfer was executed,
+ * or if no deposit was aggregated into @a wtid at all
+ */
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_get_pending_aggregation (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ struct TALER_FullPaytoHashP *wire_target_h_payto,
+ struct TALER_FullPayto *payto_uri,
+ struct TALER_Amount *total_deposited,
+ struct TALER_Amount *total_refunded,
+ struct TALER_Amount *total_deposit_fee);
+
+#endif
diff --git a/src/include/exchange-database/insert_aggregation_deferral.h b/src/include/exchange-database/insert_aggregation_deferral.h
@@ -0,0 +1,60 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/exchange-database/insert_aggregation_deferral.h
+ * @brief implementation of the insert_aggregation_deferral function for Postgres
+ * @author Christian Grothoff
+ */
+#ifndef EXCHANGE_DATABASE_INSERT_AGGREGATION_DEFERRAL_H
+#define EXCHANGE_DATABASE_INSERT_AGGREGATION_DEFERRAL_H
+
+#include "taler/taler_util.h"
+#include "exchangedb_lib.h"
+
+/**
+ * Record why the aggregator did not execute the wire transfer @a wtid that it
+ * had already decided on. Appends to `aggregation_deferrals`, which unlike
+ * `aggregation_transient` is replicated to the auditor, so that an external
+ * auditor sees the exchange's own account of what it is withholding and why
+ * instead of having to infer it.
+ *
+ * The row is tied to the deposit of the aggregate that is last to reach its
+ * wire deadline, so that garbage collecting the deposits also removes this
+ * claim about them. It is therefore an error to call this before the
+ * `aggregation_tracking` rows for @a wtid have been written; the statement
+ * inserts nothing in that case.
+ *
+ * @param pg the database context
+ * @param wtid the wire transfer that was not executed
+ * @param h_payto destination that was not paid
+ * @param amount total that was withheld
+ * @param reason why the transfer was not made
+ * @param kyc_requirement_row legitimization measure that has to be satisfied
+ * first, or 0 if @a reason is not #TALER_EXCHANGEDB_DR_KYC
+ * @param deferral_time when the decision was made
+ * @return transaction status
+ */
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_insert_aggregation_deferral (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_FullPaytoHashP *h_payto,
+ const struct TALER_Amount *amount,
+ enum TALER_EXCHANGEDB_DeferralReason reason,
+ uint64_t kyc_requirement_row,
+ struct GNUNET_TIME_Timestamp deferral_time);
+
+#endif
diff --git a/src/include/exchange-database/iterate_aggregation_wtids_above_serial_id.h b/src/include/exchange-database/iterate_aggregation_wtids_above_serial_id.h
@@ -0,0 +1,76 @@
+/*
+ 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/>
+ */
+/**
+ * @file src/include/exchange-database/iterate_aggregation_wtids_above_serial_id.h
+ * @brief implementation of the iterate_aggregation_wtids_above_serial_id function
+ * @author Christian Grothoff
+ */
+#ifndef EXCHANGE_DATABASE_ITERATE_AGGREGATION_WTIDS_ABOVE_SERIAL_ID_H
+#define EXCHANGE_DATABASE_ITERATE_AGGREGATION_WTIDS_ABOVE_SERIAL_ID_H
+
+#include "taler/taler_util.h"
+#include "exchangedb_lib.h"
+
+
+#ifndef TALER_EXCHANGEDB_AGGREGATION_WTID_RESULT_CLOSURE
+/**
+ * Type of the closure for #TALER_EXCHANGEDB_AggregationWtidCallback.
+ */
+#define TALER_EXCHANGEDB_AGGREGATION_WTID_RESULT_CLOSURE void
+#endif
+
+
+/**
+ * Function called on each row of the aggregation tracking table, telling
+ * the caller which wire transfer the aggregated deposit was assigned to.
+ *
+ * Note that many rows share the same @a wtid: one wire transfer pays out
+ * many deposits.
+ *
+ * @param cls closure
+ * @param rowid row of the entry in the aggregation tracking table
+ * @param wtid wire transfer the deposit was aggregated into
+ * @param wire_target_h_payto account the wire transfer should go to
+ * @param pending false if @a wtid already has its `wire_out` row, that is if
+ * the exchange really did make the transfer
+ * @return #GNUNET_OK to continue to iterate
+ */
+typedef enum GNUNET_GenericReturnValue
+(*TALER_EXCHANGEDB_AggregationWtidCallback)(
+ TALER_EXCHANGEDB_AGGREGATION_WTID_RESULT_CLOSURE *cls,
+ uint64_t rowid,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_FullPaytoHashP *wire_target_h_payto,
+ bool pending);
+
+
+/**
+ * Select all aggregations at or above @a serial_id, in ascending order.
+ *
+ * @param pg the database context
+ * @param serial_id highest serial ID to exclude (select strictly larger)
+ * @param cb function to call on each result
+ * @param cb_cls closure for @a cb
+ * @return query status
+ */
+enum GNUNET_DB_QueryStatus
+TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id (
+ struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint64_t serial_id,
+ TALER_EXCHANGEDB_AggregationWtidCallback cb,
+ TALER_EXCHANGEDB_AGGREGATION_WTID_RESULT_CLOSURE *cb_cls);
+
+#endif
diff --git a/src/include/exchange-database/iterate_records_by_table.h b/src/include/exchange-database/iterate_records_by_table.h
@@ -53,6 +53,7 @@ enum TALER_EXCHANGEDB_ReplicatedTable
TALER_EXCHANGEDB_RT_REFUNDS,
TALER_EXCHANGEDB_RT_WIRE_OUT,
TALER_EXCHANGEDB_RT_AGGREGATION_TRACKING,
+ TALER_EXCHANGEDB_RT_AGGREGATION_DEFERRALS,
TALER_EXCHANGEDB_RT_WIRE_FEE,
TALER_EXCHANGEDB_RT_GLOBAL_FEE,
TALER_EXCHANGEDB_RT_RECOUP,
@@ -386,6 +387,17 @@ struct TALER_EXCHANGEDB_TableData
struct
{
+ uint64_t batch_deposit_serial_id;
+ struct TALER_WireTransferIdentifierRawP wtid_raw;
+ struct TALER_FullPaytoHashP wire_target_h_payto;
+ struct TALER_Amount amount;
+ uint32_t deferral_reason;
+ uint64_t legitimization_requirement_serial_id;
+ struct GNUNET_TIME_Timestamp deferral_time;
+ } aggregation_deferrals;
+
+ struct
+ {
char *wire_method;
struct GNUNET_TIME_Timestamp start_date;
struct GNUNET_TIME_Timestamp end_date;
diff --git a/src/include/exchangedb_lib.h b/src/include/exchangedb_lib.h
@@ -40,6 +40,40 @@ enum TALER_EXCHANGEDB_PersistProgramResultStatus
/**
+ * Why the aggregator decided against executing a wire transfer it had
+ * already computed. Stored in the @e deferral_reason column of the
+ * append-only `aggregation_deferrals` table, which is the exchange's claim
+ * to the auditor about money it is holding on to. Values are part of the
+ * replicated database format and must not be renumbered.
+ */
+enum TALER_EXCHANGEDB_DeferralReason
+{
+
+ /**
+ * The exchange gave no reason. Never written; this is what the auditor
+ * books a pending transfer under when it finds no `aggregation_deferrals`
+ * row for it at all.
+ */
+ TALER_EXCHANGEDB_DR_NONE = 0,
+
+ /**
+ * What was aggregated does not survive the wire fee and the rounding to
+ * the smallest unit the wire method supports, so the exchange is waiting
+ * for further deposits to the same account to make the transfer worth
+ * making.
+ */
+ TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL = 1,
+
+ /**
+ * A legitimization requirement against the recipient account is open, so
+ * the exchange must not pay out yet. The measure in question is named by
+ * the @e legitimization_requirement_serial_id column.
+ */
+ TALER_EXCHANGEDB_DR_KYC = 2
+};
+
+
+/**
* Information about a denomination key.
*/
struct TALER_EXCHANGEDB_DenominationKeyInformation