exchange

Base system with REST service to issue digital coins, run by the payment service provider
Log | Files | Refs | Submodules | README | LICENSE

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:
Msrc/auditor/meson.build | 1+
Msrc/auditor/taler-auditor-httpd.c | 27+++++++++++++++++++++++++++
Asrc/auditor/taler-auditor-httpd_get-monitoring-aml-holds.c | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/auditor/taler-auditor-httpd_get-monitoring-aml-holds.h | 46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/auditor/taler-auditor-sync.c | 5+++++
Msrc/auditor/taler-helper-auditor-transfer.c | 684++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/auditor/test-auditor.sh | 22++++++++++++++++++++++
Msrc/auditor/test-kyc.sh | 272++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/auditor/test-sync.sh | 2+-
Asrc/auditordb/0003-auditor_aml_holds.sql | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/auditordb/delete_aml_hold.c | 44++++++++++++++++++++++++++++++++++++++++++++
Msrc/auditordb/helper.c | 1+
Asrc/auditordb/insert_aml_hold.c | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/auditordb/iterate_aml_holds.c | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/auditordb/iterate_auditor_aml_holds.c | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/auditordb/meson.build | 6++++++
Msrc/auditordb/restart.sql | 1+
Asrc/auditordb/update_aml_hold.c | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/exchange/taler-exchange-aggregator.c | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/get_aggregation_deferral_by_wtid.c | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/get_aggregation_transient_by_wtid.c | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/get_open_legitimization_measure.c | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/get_pending_aggregation.c | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/exchangedb/get_serial_by_table.c | 8++++++++
Asrc/exchangedb/insert_aggregation_deferral.c | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/exchangedb/insert_records_by_table.c | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/iterate_aggregation_wtids_above_serial_id.c | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/exchangedb/iterate_records_by_table.c | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/exchangedb/meson.build | 6++++++
Asrc/exchangedb/sql-schema/0012-aggregation_deferrals.sql | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/exchangedb/sql-schema/0012-preamble.sql | 20++++++++++++++++++++
Msrc/exchangedb/sql-schema/meson.build | 7+++++++
Asrc/include/auditor-database/delete_aml_hold.h | 40++++++++++++++++++++++++++++++++++++++++
Asrc/include/auditor-database/insert_aml_hold.h | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/auditor-database/iterate_aml_holds.h | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/auditor-database/iterate_auditor_aml_holds.h | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/auditor-database/update_aml_hold.h | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/auditordb_lib.h | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/exchange-database/get_aggregation_deferral_by_wtid.h | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/exchange-database/get_aggregation_transient_by_wtid.h | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/exchange-database/get_open_legitimization_measure.h | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/exchange-database/get_pending_aggregation.h | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/exchange-database/insert_aggregation_deferral.h | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/exchange-database/iterate_aggregation_wtids_above_serial_id.h | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/exchange-database/iterate_records_by_table.h | 12++++++++++++
Msrc/include/exchangedb_lib.h | 34++++++++++++++++++++++++++++++++++
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), + &note_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